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.
@@ -0,0 +1,31 @@
1
+ import { TableIcon } from 'lucide-react'
2
+ import type { ReactNode } from 'react'
3
+
4
+ export default function Empty({ emptyState }: { emptyState?: ReactNode }) {
5
+ if (emptyState) {
6
+ return (
7
+ <tbody className='block w-full'>
8
+ <tr className='flex w-full'>
9
+ <td className='w-full'>
10
+ <div className='flex items-center justify-center min-h-[160px] py-8 px-6'>
11
+ {emptyState}
12
+ </div>
13
+ </td>
14
+ </tr>
15
+ </tbody>
16
+ )
17
+ }
18
+
19
+ return (
20
+ <tbody className='block w-full'>
21
+ <tr className='flex w-full'>
22
+ <td className='w-full'>
23
+ <div className='flex flex-col items-center justify-center gap-3 min-h-[160px] py-8 text-login-300'>
24
+ <TableIcon className='h-10 w-10 opacity-30' />
25
+ <span className='text-sm'>No data found</span>
26
+ </div>
27
+ </td>
28
+ </tr>
29
+ </tbody>
30
+ )
31
+ }
@@ -1,35 +1,42 @@
1
- const nullableTimeKeys = ['date', 'last_sent', 'time']
1
+ const nullableTimeKeys = new Set(['date', 'last_sent', 'sent_at', 'time'])
2
2
 
3
- const ISODateTimeReg = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/
4
- const ISODateReg = /^\d{4}-\d{2}-\d{2}$/
5
- const ISOTimeReg = /^\d{2}:\d{2}(:\d{2})?$/
3
+ const RX_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/
4
+ const RX_DATE = /^\d{4}-\d{2}-\d{2}$/
5
+ const RX_TIME = /^\d{2}:\d{2}(:\d{2})?$/
6
6
 
7
- const OsloTZ = { timeZone: 'Europe/Oslo' } as const
8
- const OsloTime: Intl.DateTimeFormatOptions = { ...OsloTZ, hour: '2-digit', minute: '2-digit' }
9
- const OsloDateTime: Intl.DateTimeFormatOptions = { ...OsloTime, year: 'numeric', month: '2-digit', day: '2-digit' }
7
+ export function formatValue(key: string, value: unknown): string | number {
8
+ if (value === null || value === undefined) {
9
+ return nullableTimeKeys.has(key) ? 'Never' : '-'
10
+ }
11
+
12
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No'
10
13
 
11
- export function formatValue(key: string, value: string | number) {
12
- if (nullableTimeKeys.includes(key) && !value) {
13
- return 'Never'
14
+ if (typeof value === 'number') {
15
+ if (key.includes('capacity') && value === 0) return 'Unlimited'
16
+ return value
14
17
  }
15
18
 
16
19
  if (typeof value === 'string') {
17
- if (ISODateTimeReg.test(value)) {
18
- return new Date(value).toLocaleString('nb-NO', OsloDateTime)
20
+ if (RX_DATETIME.test(value)) {
21
+ return new Date(value).toLocaleString('nb-NO', {
22
+ timeZone: 'Europe/Oslo',
23
+ year: 'numeric', month: '2-digit', day: '2-digit',
24
+ hour: '2-digit', minute: '2-digit',
25
+ })
19
26
  }
20
-
21
- if (ISODateReg.test(value)) {
22
- return new Date(value).toLocaleDateString('nb-NO', OsloTZ)
27
+ if (RX_DATE.test(value)) {
28
+ return new Date(value).toLocaleDateString('nb-NO', { timeZone: 'Europe/Oslo' })
23
29
  }
24
-
25
- if (ISOTimeReg.test(value)) {
26
- return new Date(`1970-01-01T${value}`).toLocaleTimeString('nb-NO', OsloTime)
30
+ if (RX_TIME.test(value)) {
31
+ return new Date(`1970-01-01T${value}`).toLocaleTimeString('nb-NO', {
32
+ timeZone: 'Europe/Oslo',
33
+ hour: '2-digit', minute: '2-digit',
34
+ })
27
35
  }
36
+ return value
28
37
  }
29
38
 
30
- if (key.includes('capacity')) {
31
- return value === 0 ? 'Unlimited' : value
32
- }
39
+ if (Array.isArray(value)) return value.join(', ')
33
40
 
34
- return value
41
+ return String(value)
35
42
  }
@@ -1,94 +1,139 @@
1
- import { ChevronDown, ChevronUp } from 'lucide-react'
2
- import { usePathname, useRouter, useSearchParams } from 'next/navigation'
3
- import { useEffect, useState } from 'react'
4
- import type { Column } from 'uibee/components'
1
+ 'use client'
5
2
 
6
- type HeaderProps = {
7
- columns: Column[]
8
- hideMenu?: boolean
9
- variant?: 'default' | 'minimal'
10
- }
3
+ import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
4
+ import type { Column, SortState, Density, TableVariant } from './types'
5
+ import { DENSITY_TH, VARIANT_THEAD, VARIANT_THEAD_TH } from './constants'
11
6
 
12
- function parseOrder(value: string | null) {
13
- return value === 'asc' || value === 'desc' ? value : undefined
7
+ type HeaderProps<T extends Record<string, unknown>> = {
8
+ columns: Column<T>[]
9
+ sort?: SortState
10
+ onSort: (sort: SortState) => void
11
+ hasMenu: boolean
12
+ hasSelect: boolean
13
+ hasExpand: boolean
14
+ allSelected: boolean
15
+ someSelected: boolean
16
+ onSelectAll: () => void
17
+ variant: TableVariant
18
+ density: Density
14
19
  }
15
20
 
16
- export default function Header({ columns, hideMenu, variant = 'default' }: HeaderProps) {
17
- const router = useRouter()
18
- const pathname = usePathname()
19
- const searchParams = useSearchParams()
20
- const [column, setColumn] = useState(searchParams.get('column') ?? '')
21
- const [order, setOrder] = useState<'asc' | 'desc' | undefined>(parseOrder(searchParams.get('order')))
22
-
23
- useEffect(() => {
24
- if (!column || !order) {
25
- return
26
- }
21
+ // Title case from any key format: last_active → "Last Active", cpuPct "Cpu Pct", id → "ID"
22
+ function formatLabel(key: string, label?: string): string {
23
+ if (label) return label
24
+ if (key.length <= 2) return key.toUpperCase()
25
+ return key
26
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // split camelCase
27
+ .replaceAll('_', ' ')
28
+ .replaceAll('-', ' ')
29
+ .split(/\s+/)
30
+ .filter(Boolean)
31
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
32
+ .join(' ')
33
+ }
27
34
 
28
- const params = new URLSearchParams(searchParams.toString())
29
- if (
30
- searchParams.get('order') !== order ||
31
- searchParams.get('column') !== column
32
- ) {
33
- params.set('order', order)
34
- params.set('column', column)
35
- params.set('page', '1')
36
- router.push(`${pathname}?${params.toString()}`)
37
- }
38
- }, [order, column, pathname, router, searchParams])
35
+ export default function Header<T extends Record<string, unknown>>({
36
+ columns, sort, onSort, hasMenu, hasSelect, hasExpand,
37
+ allSelected, someSelected, onSelectAll, variant, density,
38
+ }: HeaderProps<T>) {
39
39
 
40
- function handleChange(key: string) {
41
- setColumn(key)
42
- setOrder((prev) => (key === column && prev === 'asc' ? 'desc' : 'asc'))
40
+ function handleSort(key: string) {
41
+ const sameCol = sort?.column === key
42
+ onSort({ column: key, order: sameCol && sort?.order === 'asc' ? 'desc' : 'asc' })
43
43
  }
44
44
 
45
45
  return (
46
- <thead className={`
47
- block w-full
48
- ${variant === 'default' ? 'bg-login-700' : 'bg-transparent border-b border-login-600'}
49
- `}>
46
+ <thead className={`block w-full sticky top-0 z-10 ${VARIANT_THEAD[variant]}`}>
50
47
  <tr className='flex w-full'>
48
+
49
+ {hasSelect && (
50
+ <th className='flex items-center justify-center'
51
+ style={{ width: '3rem', minWidth: '3rem', flexShrink: 0 }}>
52
+ <button
53
+ type='button'
54
+ aria-label={allSelected ? 'Deselect all' : 'Select all'}
55
+ onClick={onSelectAll}
56
+ className={`
57
+ h-4 w-4 rounded border flex items-center justify-center transition-colors cursor-pointer
58
+ ${allSelected || someSelected
59
+ ? 'bg-login border-login text-white'
60
+ : 'border-login-400 bg-transparent hover:border-login-200'
61
+ }
62
+ `}
63
+ >
64
+ {someSelected && !allSelected && (
65
+ <span className='block h-0.5 w-2 bg-white rounded-full' />
66
+ )}
67
+ {allSelected && (
68
+ <svg className='h-3 w-3' viewBox='0 0 12 12' fill='none'>
69
+ <path d='M2 6l3 3 5-5' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round' />
70
+ </svg>
71
+ )}
72
+ </button>
73
+ </th>
74
+ )}
75
+
51
76
  {columns.map((col) => {
52
- const key = col.key
53
- const value = col.label || (
54
- key.length < 3
55
- ? key.toUpperCase()
56
- : `${key[0].toUpperCase()}${key
57
- .slice(1)
58
- .replaceAll('_', ' ')}`
59
- )
77
+ const sortable = col.sortable !== false
78
+ const isActive = sort?.column === col.key
79
+ const ariaSort = isActive
80
+ ? sort!.order === 'asc' ? 'ascending' : 'descending'
81
+ : 'none'
82
+
83
+ const alignClass =
84
+ col.align === 'right' ? 'justify-end' :
85
+ col.align === 'center' ? 'justify-center' : ''
86
+
60
87
  return (
61
88
  <th
62
- key={key}
89
+ key={col.key}
90
+ aria-sort={sortable ? ariaSort : undefined}
91
+ style={col.width ? { width: col.width, flexShrink: 0 } : undefined}
63
92
  className={`
64
- flex-1 min-w-0 px-6 py-3 text-xs font-medium uppercase tracking-wider text-left
65
- ${variant === 'default' ? 'text-login-200' : 'text-login-100'}
66
- ${variant === 'minimal' ? 'px-4!' : ''}
93
+ ${col.width ? '' : 'flex-1'}
94
+ text-xs font-medium uppercase tracking-wider
95
+ ${DENSITY_TH[density]} ${VARIANT_THEAD_TH[variant]}
67
96
  `}
68
97
  >
69
- <button
70
- className='flex w-full min-w-0 flex-row items-center gap-2 group uppercase whitespace-nowrap'
71
- onClick={() => handleChange(key)}
72
- >
73
- <span className='min-w-0 truncate'>{value}</span>
74
- <span className='flex flex-col'>
75
- {column === key ? (
76
- order === 'asc' ? (
77
- <ChevronUp className='h-4 w-4' />
98
+ {sortable ? (
99
+ <button
100
+ type='button'
101
+ className={`group inline-flex items-center gap-1.5 w-full cursor-pointer ${alignClass}`}
102
+ onClick={() => handleSort(col.key)}
103
+ >
104
+ <span className='whitespace-nowrap'>{formatLabel(col.key, col.label)}</span>
105
+ <span className='shrink-0 text-current'>
106
+ {isActive ? (
107
+ sort!.order === 'asc'
108
+ ? <ChevronUp className='h-3.5 w-3.5' />
109
+ : <ChevronDown className='h-3.5 w-3.5' />
78
110
  ) : (
79
- <ChevronDown className='h-4 w-4' />
80
- )
81
- ) : (
82
- <ChevronUp
83
- className='h-4 w-4 stroke-login-200 opacity-0 group-hover:opacity-100'
84
- />
85
- )}
111
+ <ChevronsUpDown className='h-3.5 w-3.5 opacity-0 group-hover:opacity-35 transition-opacity' />
112
+ )}
113
+ </span>
114
+ </button>
115
+ ) : (
116
+ <span className={`flex w-full ${alignClass}`}>
117
+ {formatLabel(col.key, col.label)}
86
118
  </span>
87
- </button>
119
+ )}
88
120
  </th>
89
121
  )
90
122
  })}
91
- {!hideMenu && <th className='shrink-0 w-16 px-6 py-3' />}
123
+
124
+ {hasExpand && (
125
+ <th className='flex items-center justify-center'
126
+ style={{ width: '2.5rem', minWidth: '2.5rem', flexShrink: 0 }}>
127
+ <span className={`text-xs font-medium tracking-wider uppercase opacity-50 ${VARIANT_THEAD_TH[variant]}`}>
128
+ +
129
+ </span>
130
+ </th>
131
+ )}
132
+
133
+ {hasMenu && (
134
+ <th aria-hidden='true'
135
+ style={{ width: '3.5rem', minWidth: '3.5rem', flexShrink: 0 }} />
136
+ )}
92
137
  </tr>
93
138
  </thead>
94
139
  )
@@ -1,25 +1,38 @@
1
1
  'use client'
2
2
 
3
- import React, { createContext, useContext, SVGProps, useEffect } from 'react'
3
+ import React, { createContext, useContext, useEffect } from 'react'
4
4
  import { createPortal } from 'react-dom'
5
+ import type { MenuAnchor } from './types'
5
6
 
6
- const MenuContext = createContext<{ onClose?: () => void }>({})
7
+ const MenuCtx = createContext<{ onClose?: () => void }>({})
7
8
 
8
- export default function Menu({ ref, children, anchor, onClose }: {
9
+ type MenuProps = {
9
10
  ref: React.RefObject<HTMLDivElement | null>
10
11
  children: React.ReactNode
11
- anchor: { top: number; right: number }
12
+ anchor: MenuAnchor
12
13
  onClose?: () => void
13
- }) {
14
+ }
15
+
16
+ export default function Menu({ ref, children, anchor, onClose }: MenuProps) {
17
+ useEffect(() => {
18
+ function handleKey(e: KeyboardEvent) {
19
+ if (e.key === 'Escape') onClose?.()
20
+ }
21
+ window.addEventListener('keydown', handleKey)
22
+ return () => window.removeEventListener('keydown', handleKey)
23
+ }, [onClose])
24
+
14
25
  return createPortal(
15
26
  <div
16
27
  ref={ref}
28
+ role='menu'
29
+ aria-orientation='vertical'
17
30
  style={{ top: anchor.top, right: anchor.right }}
18
- className='fixed bg-login-500 border border-login-600 rounded-lg shadow-lg z-9999 w-44'
31
+ className='fixed z-[9999] w-44 overflow-hidden rounded-lg border border-login-500/60 bg-login-600 shadow-xl shadow-black/40'
19
32
  >
20
- <MenuContext.Provider value={{ onClose }}>
33
+ <MenuCtx.Provider value={{ onClose }}>
21
34
  {children}
22
- </MenuContext.Provider>
35
+ </MenuCtx.Provider>
23
36
  </div>,
24
37
  document.body
25
38
  )
@@ -30,47 +43,50 @@ export function MenuButton({
30
43
  text,
31
44
  hotKey,
32
45
  onClick,
33
- className,
46
+ className = '',
34
47
  }: {
35
48
  icon: React.ReactNode
36
49
  text: string
37
50
  hotKey?: string
38
51
  onClick: () => void
39
52
  className?: string
40
- }
41
- ){
42
- const { onClose } = useContext(MenuContext)
53
+ }) {
54
+ const { onClose } = useContext(MenuCtx)
43
55
 
44
56
  useEffect(() => {
45
57
  if (!hotKey) return
46
-
47
- function handleKeyDown(e: KeyboardEvent) {
58
+ function handleKey(e: KeyboardEvent) {
59
+ const tag = (e.target as HTMLElement).tagName
60
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return
48
61
  if (e.key.toLowerCase() === hotKey!.toLowerCase()) {
49
62
  e.preventDefault()
50
63
  onClick()
51
64
  onClose?.()
52
65
  }
53
66
  }
54
-
55
- window.addEventListener('keydown', handleKeyDown)
56
- return () => window.removeEventListener('keydown', handleKeyDown)
67
+ window.addEventListener('keydown', handleKey)
68
+ return () => window.removeEventListener('keydown', handleKey)
57
69
  }, [hotKey, onClick, onClose])
58
70
 
59
71
  return (
60
72
  <button
61
- onClick={() => {
62
- onClick()
63
- onClose?.()
64
- }}
65
- className={`flex items-center justify-between w-full px-3 py-2 text-sm hover:bg-login-600 cursor-pointer
66
- ${className || ''}
73
+ role='menuitem'
74
+ onClick={() => { onClick(); onClose?.() }}
75
+ className={`
76
+ flex w-full items-center justify-between px-3 py-2 text-sm
77
+ text-login-75 transition-colors duration-100
78
+ hover:bg-login-500 focus:bg-login-500 focus:outline-none
79
+ first:rounded-t-lg last:rounded-b-lg
80
+ ${className}
67
81
  `}
68
82
  >
69
- <div className='flex items-center'>
70
- {React.cloneElement(icon as React.ReactElement<SVGProps<SVGSVGElement>>, { className: 'w-4 h-4 mr-2' })}
83
+ <span className='flex items-center gap-2'>
84
+ <span className='h-4 w-4 shrink-0 flex items-center justify-center'>{icon}</span>
71
85
  {text}
72
- </div>
73
- <span className='text-xs opacity-50 font-mono'>{hotKey}</span>
86
+ </span>
87
+ {hotKey && (
88
+ <kbd className='font-mono text-xs opacity-40'>{hotKey}</kbd>
89
+ )}
74
90
  </button>
75
91
  )
76
92
  }