uibee 3.1.1 → 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/dist/src/components/index.d.ts +59 -27
- package/dist/src/components/index.js +825 -339
- package/dist/style.css +283 -67
- package/package.json +1 -1
- package/src/components/buttons/button.tsx +6 -3
- package/src/components/index.ts +2 -17
- package/src/components/navbar/navbar.tsx +0 -5
- package/src/components/navbar/navbarDropdown.tsx +0 -2
- package/src/components/navbar/navbarItem.tsx +0 -2
- package/src/components/table/body.tsx +260 -153
- package/src/components/table/constants.ts +53 -0
- package/src/components/table/empty.tsx +31 -0
- package/src/components/table/format.ts +29 -22
- package/src/components/table/header.tsx +115 -70
- package/src/components/table/menu.tsx +43 -27
- package/src/components/table/pagination.tsx +162 -136
- package/src/components/table/skeleton.tsx +56 -0
- package/src/components/table/table.tsx +261 -34
- package/src/components/table/types.ts +94 -0
- package/src/components/table/utils.ts +12 -0
- package/src/components/toggle/theme.tsx +0 -3
- package/src/globals.css +11 -0
- package/src/utils/auth/callback.ts +0 -2
- package/src/utils/index.ts +0 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
|
4
|
-
const
|
|
5
|
-
const
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
return
|
|
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 (
|
|
18
|
-
return new Date(value).toLocaleString('nb-NO',
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
${variant
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
<
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
119
|
+
)}
|
|
88
120
|
</th>
|
|
89
121
|
)
|
|
90
122
|
})}
|
|
91
|
-
|
|
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,
|
|
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
|
|
7
|
+
const MenuCtx = createContext<{ onClose?: () => void }>({})
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
type MenuProps = {
|
|
9
10
|
ref: React.RefObject<HTMLDivElement | null>
|
|
10
11
|
children: React.ReactNode
|
|
11
|
-
anchor:
|
|
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
|
|
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
|
-
<
|
|
33
|
+
<MenuCtx.Provider value={{ onClose }}>
|
|
21
34
|
{children}
|
|
22
|
-
</
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
<
|
|
70
|
-
|
|
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
|
-
</
|
|
73
|
-
|
|
86
|
+
</span>
|
|
87
|
+
{hotKey && (
|
|
88
|
+
<kbd className='font-mono text-xs opacity-40'>{hotKey}</kbd>
|
|
89
|
+
)}
|
|
74
90
|
</button>
|
|
75
91
|
)
|
|
76
92
|
}
|