@goplusvn/core 0.1.19 → 0.1.21
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 +1 -1
- package/src/ui/data-display/data-table/data-table.tsx +1 -1
- package/src/ui/data-display/data-table-pagination.tsx +9 -9
- package/src/ui/data-display/url-pagination.tsx +178 -0
- package/src/ui/forms/multi-select.tsx +89 -107
- package/src/ui/forms/update_multiselect.py +125 -0
- package/src/ui/forms/update_search.py +48 -0
- package/src/ui/forms/upgrade_multiselect.py +119 -0
- package/src/ui/index.tsx +2 -0
- package/src/ui/primitives/combobox.tsx +2 -2
- package/src/ui/primitives/select.tsx +1 -1
package/package.json
CHANGED
|
@@ -480,7 +480,7 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
480
480
|
|
|
481
481
|
{/* Pagination Section */}
|
|
482
482
|
{pagination && (
|
|
483
|
-
<div className="border-t border-border dark:border-slate-800 bg-card px-
|
|
483
|
+
<div className="border-t border-border dark:border-slate-800 bg-card px-2 sm:px-4 py-1 shrink-0">
|
|
484
484
|
<MemoizedPagination
|
|
485
485
|
table={table}
|
|
486
486
|
currentPage={pagination.page}
|
|
@@ -65,21 +65,21 @@ export function DataTablePagination<TData>({
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
return (
|
|
68
|
-
<div className="flex flex-col sm:flex-row items-center justify-between gap-1
|
|
69
|
-
<div className="hidden sm:block flex-1 text-
|
|
68
|
+
<div className="flex flex-col sm:flex-row items-center justify-between gap-1 px-1 sm:px-2">
|
|
69
|
+
<div className="hidden sm:block flex-1 text-[11px] text-left text-muted-foreground w-full">
|
|
70
70
|
Hiển thị {startItem} - {endItem} trong tổng số {total} mục
|
|
71
71
|
</div>
|
|
72
72
|
|
|
73
|
-
<div className="flex items-center justify-center gap-2 w-full sm:w-auto overflow-x-auto
|
|
74
|
-
<div className="flex items-center gap-1
|
|
75
|
-
<p className="hidden sm:block text-
|
|
73
|
+
<div className="flex items-center justify-center gap-2 w-full sm:w-auto overflow-x-auto">
|
|
74
|
+
<div className="flex items-center gap-1 gap-1 px-1 border-r pr-1 sm:pr-1.5">
|
|
75
|
+
<p className="hidden sm:block text-[11px] font-medium whitespace-nowrap">
|
|
76
76
|
Số dòng
|
|
77
77
|
</p>
|
|
78
78
|
<Select
|
|
79
79
|
value={`${effPageSize}`}
|
|
80
80
|
onValueChange={(value) => table.setPageSize(Number(value))}
|
|
81
81
|
>
|
|
82
|
-
<SelectTrigger className="h-6 w-[
|
|
82
|
+
<SelectTrigger className="!h-6 !min-h-0 py-0 w-[60px] sm:w-[65px] text-[11px] px-1.5 focus:ring-inset focus:ring-offset-0">
|
|
83
83
|
<SelectValue placeholder={effPageSize} />
|
|
84
84
|
</SelectTrigger>
|
|
85
85
|
<SelectContent side="top">
|
|
@@ -113,11 +113,11 @@ export function DataTablePagination<TData>({
|
|
|
113
113
|
</Button>
|
|
114
114
|
|
|
115
115
|
<div className="flex items-center gap-1 sm:gap-2 px-1">
|
|
116
|
-
<span className="hidden sm:inline text-
|
|
116
|
+
<span className="hidden sm:inline text-[11px] font-medium whitespace-nowrap">
|
|
117
117
|
Trang
|
|
118
118
|
</span>
|
|
119
119
|
<input
|
|
120
|
-
className="h-6 w-10 sm:w-12 rounded-md border border-input bg-background px-1 text-
|
|
120
|
+
className="h-6 w-10 sm:w-12 rounded-md border border-input bg-background px-1 text-[11px] text-center focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
121
121
|
value={inputPage}
|
|
122
122
|
onChange={(e) => setInputPage(e.target.value)}
|
|
123
123
|
onBlur={handlePageInputBlur}
|
|
@@ -126,7 +126,7 @@ export function DataTablePagination<TData>({
|
|
|
126
126
|
}}
|
|
127
127
|
disabled={total === 0}
|
|
128
128
|
/>
|
|
129
|
-
<span className="text-
|
|
129
|
+
<span className="text-[11px] font-medium text-muted-foreground whitespace-nowrap">
|
|
130
130
|
/ {pageCount}
|
|
131
131
|
</span>
|
|
132
132
|
</div>
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|
5
|
+
import {
|
|
6
|
+
Button,
|
|
7
|
+
Select,
|
|
8
|
+
SelectContent,
|
|
9
|
+
SelectItem,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue,
|
|
12
|
+
} from "../primitives"
|
|
13
|
+
import {
|
|
14
|
+
ChevronLeft,
|
|
15
|
+
ChevronRight,
|
|
16
|
+
ChevronsLeft,
|
|
17
|
+
ChevronsRight,
|
|
18
|
+
} from "lucide-react"
|
|
19
|
+
|
|
20
|
+
interface PaginationProps {
|
|
21
|
+
totalItems: number
|
|
22
|
+
currentPage: number
|
|
23
|
+
pageSize: number
|
|
24
|
+
disabled?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function UrlPagination({
|
|
28
|
+
totalItems,
|
|
29
|
+
currentPage,
|
|
30
|
+
pageSize,
|
|
31
|
+
disabled,
|
|
32
|
+
}: PaginationProps) {
|
|
33
|
+
const router = useRouter()
|
|
34
|
+
const pathname = usePathname()
|
|
35
|
+
const searchParams = useSearchParams()
|
|
36
|
+
|
|
37
|
+
const pageCount = Math.ceil(totalItems / pageSize)
|
|
38
|
+
const startItem = (currentPage - 1) * pageSize + 1
|
|
39
|
+
const endItem = Math.min(currentPage * pageSize, totalItems)
|
|
40
|
+
|
|
41
|
+
// Create a new URLSearchParams instance to update params
|
|
42
|
+
const createQueryString = React.useCallback(
|
|
43
|
+
(params: Record<string, string | number | null>) => {
|
|
44
|
+
const newSearchParams = new URLSearchParams(searchParams?.toString())
|
|
45
|
+
|
|
46
|
+
for (const [key, value] of Object.entries(params)) {
|
|
47
|
+
if (value === null) {
|
|
48
|
+
newSearchParams.delete(key)
|
|
49
|
+
} else {
|
|
50
|
+
newSearchParams.set(key, String(value))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return newSearchParams.toString()
|
|
55
|
+
},
|
|
56
|
+
[searchParams]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const handlePageChange = (page: number) => {
|
|
60
|
+
if (page < 1 || page > Math.max(1, pageCount)) return
|
|
61
|
+
router.push(`${pathname}?${createQueryString({ page })}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const handlePageSizeChange = (value: string) => {
|
|
65
|
+
const newPageSize = Number(value)
|
|
66
|
+
// When changing page size, reset to page 1 to avoid out of bounds
|
|
67
|
+
router.push(
|
|
68
|
+
`${pathname}?${createQueryString({ pageSize: newPageSize, page: 1 })}`
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const [inputPage, setInputPage] = React.useState(String(currentPage))
|
|
73
|
+
|
|
74
|
+
React.useEffect(() => {
|
|
75
|
+
setInputPage(String(currentPage))
|
|
76
|
+
}, [currentPage])
|
|
77
|
+
|
|
78
|
+
const handlePageInputBlur = () => {
|
|
79
|
+
const pageNum = Number(inputPage)
|
|
80
|
+
if (isNaN(pageNum) || pageNum < 1 || pageNum > pageCount) {
|
|
81
|
+
setInputPage(String(currentPage))
|
|
82
|
+
} else if (pageNum !== currentPage) {
|
|
83
|
+
handlePageChange(pageNum)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const handlePageInputKeyDown = (e: React.KeyboardEvent) => {
|
|
88
|
+
if (e.key === "Enter") {
|
|
89
|
+
handlePageInputBlur()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex flex-col sm:flex-row items-center justify-between gap-1 sm:gap-2 px-1 sm:px-2">
|
|
95
|
+
{/* Ẩn text "Hiển thị" trên mobile, chỉ hiện trên màn hình sm trở lên */}
|
|
96
|
+
<div className="hidden sm:block flex-1 text-[11px] text-left text-muted-foreground w-full">
|
|
97
|
+
Hiển thị {totalItems > 0 ? startItem : 0} -{" "}
|
|
98
|
+
{totalItems > 0 ? endItem : 0} trong tổng số {totalItems} mục
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div className="flex items-center justify-center gap-2 w-full sm:w-auto overflow-x-auto">
|
|
102
|
+
<div className="flex items-center gap-1 gap-1 px-1 border-r pr-1 sm:pr-1.5">
|
|
103
|
+
<p className="hidden sm:block text-[11px] font-medium whitespace-nowrap">Số dòng</p>
|
|
104
|
+
<Select
|
|
105
|
+
value={`${pageSize}`}
|
|
106
|
+
onValueChange={handlePageSizeChange}
|
|
107
|
+
disabled={disabled}
|
|
108
|
+
>
|
|
109
|
+
<SelectTrigger className="!h-6 !min-h-0 py-0 w-[60px] sm:w-[65px] text-[11px] px-1.5 focus:ring-inset focus:ring-offset-0">
|
|
110
|
+
<SelectValue placeholder={pageSize} />
|
|
111
|
+
</SelectTrigger>
|
|
112
|
+
<SelectContent side="top">
|
|
113
|
+
{[10, 20, 50, 100, 500, 1000].map((size) => (
|
|
114
|
+
<SelectItem key={size} value={`${size}`}>
|
|
115
|
+
{size}
|
|
116
|
+
</SelectItem>
|
|
117
|
+
))}
|
|
118
|
+
</SelectContent>
|
|
119
|
+
</Select>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="flex items-center gap-1 pl-1">
|
|
122
|
+
<Button
|
|
123
|
+
variant="outline"
|
|
124
|
+
className="hidden h-6 w-6 p-0 lg:flex"
|
|
125
|
+
onClick={() => handlePageChange(1)}
|
|
126
|
+
disabled={currentPage <= 1 || disabled || totalItems === 0}
|
|
127
|
+
>
|
|
128
|
+
<span className="sr-only">Trang đầu</span>
|
|
129
|
+
<ChevronsLeft className="h-3 w-3 sm:h-3 sm:w-3" />
|
|
130
|
+
</Button>
|
|
131
|
+
<Button
|
|
132
|
+
variant="outline"
|
|
133
|
+
className="h-6 w-6 p-0"
|
|
134
|
+
onClick={() => handlePageChange(currentPage - 1)}
|
|
135
|
+
disabled={currentPage <= 1 || disabled || totalItems === 0}
|
|
136
|
+
>
|
|
137
|
+
<span className="sr-only">Trang trước</span>
|
|
138
|
+
<ChevronLeft className="h-3 w-3 sm:h-3 sm:w-3" />
|
|
139
|
+
</Button>
|
|
140
|
+
|
|
141
|
+
<div className="flex items-center gap-1 sm:gap-2 px-1">
|
|
142
|
+
<span className="hidden sm:inline text-[11px] font-medium whitespace-nowrap">Trang</span>
|
|
143
|
+
<input
|
|
144
|
+
className="h-6 w-10 sm:w-12 rounded-md border border-input bg-background px-1 text-[11px] text-center focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
145
|
+
value={inputPage}
|
|
146
|
+
onChange={(e) => setInputPage(e.target.value)}
|
|
147
|
+
onBlur={handlePageInputBlur}
|
|
148
|
+
onKeyDown={handlePageInputKeyDown}
|
|
149
|
+
disabled={disabled || totalItems === 0}
|
|
150
|
+
/>
|
|
151
|
+
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
|
|
152
|
+
/ {Math.max(1, pageCount)}
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<Button
|
|
157
|
+
variant="outline"
|
|
158
|
+
className="h-6 w-6 p-0"
|
|
159
|
+
onClick={() => handlePageChange(currentPage + 1)}
|
|
160
|
+
disabled={currentPage >= pageCount || disabled || totalItems === 0}
|
|
161
|
+
>
|
|
162
|
+
<span className="sr-only">Trang sau</span>
|
|
163
|
+
<ChevronRight className="h-3 w-3 sm:h-3 sm:w-3" />
|
|
164
|
+
</Button>
|
|
165
|
+
<Button
|
|
166
|
+
variant="outline"
|
|
167
|
+
className="hidden h-6 w-6 p-0 lg:flex"
|
|
168
|
+
onClick={() => handlePageChange(pageCount)}
|
|
169
|
+
disabled={currentPage >= pageCount || disabled || totalItems === 0}
|
|
170
|
+
>
|
|
171
|
+
<span className="sr-only">Trang cuối</span>
|
|
172
|
+
<ChevronsRight className="h-3 w-3 sm:h-3 sm:w-3" />
|
|
173
|
+
</Button>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
@@ -16,6 +16,8 @@ export interface MultiSelectOption {
|
|
|
16
16
|
disabled?: boolean;
|
|
17
17
|
/** Optional icon component to display alongside the option */
|
|
18
18
|
icon?: React.ComponentType<{ className?: string }>;
|
|
19
|
+
/** Optional count to display on the right side of the option */
|
|
20
|
+
count?: number;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
interface MultiSelectProps {
|
|
@@ -191,11 +193,20 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
191
193
|
if (!searchValue.trim()) {
|
|
192
194
|
return options;
|
|
193
195
|
}
|
|
194
|
-
const
|
|
196
|
+
const removeAccents = (str: string) => {
|
|
197
|
+
return str ? str.normalize('NFD').replace(/[\u0300-\u036f]/g, '') : '';
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const searchLower = removeAccents(searchValue.toLowerCase());
|
|
201
|
+
|
|
195
202
|
return options.filter(
|
|
196
|
-
(option) =>
|
|
197
|
-
option.label.toLowerCase()
|
|
198
|
-
|
|
203
|
+
(option) => {
|
|
204
|
+
const labelLower = option.label ? removeAccents(option.label.toLowerCase()) : '';
|
|
205
|
+
const valueLower = option.value !== undefined && option.value !== null
|
|
206
|
+
? removeAccents(String(option.value).toLowerCase())
|
|
207
|
+
: '';
|
|
208
|
+
return labelLower.includes(searchLower) || valueLower.includes(searchLower);
|
|
209
|
+
}
|
|
199
210
|
);
|
|
200
211
|
}, [options, searchValue]);
|
|
201
212
|
|
|
@@ -391,78 +402,44 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
391
402
|
setOpen(!open);
|
|
392
403
|
}}
|
|
393
404
|
>
|
|
394
|
-
<div className="flex flex-wrap gap-1">
|
|
395
|
-
{
|
|
396
|
-
<>
|
|
397
|
-
{selectedOptions
|
|
398
|
-
.slice(0, responsiveSettings.maxCount)
|
|
399
|
-
.map((option, index) => (
|
|
400
|
-
<Badge
|
|
401
|
-
key={String(option.value)}
|
|
402
|
-
className={cn(
|
|
403
|
-
"mr-1 mb-1",
|
|
404
|
-
getBadgeColor(index),
|
|
405
|
-
responsiveSettings.compactMode &&
|
|
406
|
-
"text-xs px-1.5 py-0.5",
|
|
407
|
-
)}
|
|
408
|
-
onClick={(e) => handleRemove(option.value, e)}
|
|
409
|
-
>
|
|
410
|
-
{option.icon && !responsiveSettings.compactMode && (
|
|
411
|
-
<option.icon className="mr-2 h-4 w-4" />
|
|
412
|
-
)}
|
|
413
|
-
{option.label}
|
|
414
|
-
<div
|
|
415
|
-
role="button"
|
|
416
|
-
tabIndex={0}
|
|
417
|
-
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
|
|
418
|
-
onKeyDown={(e) => {
|
|
419
|
-
if (e.key === "Enter") {
|
|
420
|
-
handleRemove(option.value, e as any);
|
|
421
|
-
}
|
|
422
|
-
}}
|
|
423
|
-
onMouseDown={(e) => {
|
|
424
|
-
e.preventDefault();
|
|
425
|
-
e.stopPropagation();
|
|
426
|
-
}}
|
|
427
|
-
onClick={(e) => handleRemove(option.value, e)}
|
|
428
|
-
>
|
|
429
|
-
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
|
430
|
-
</div>
|
|
431
|
-
</Badge>
|
|
432
|
-
))}
|
|
433
|
-
{value.length > responsiveSettings.maxCount && (
|
|
434
|
-
<Badge
|
|
435
|
-
className={cn(
|
|
436
|
-
"mr-1 mb-1 bg-muted text-muted-foreground hover:bg-muted",
|
|
437
|
-
responsiveSettings.compactMode && "text-xs px-1.5 py-0.5",
|
|
438
|
-
)}
|
|
439
|
-
>
|
|
440
|
-
+{value.length - responsiveSettings.maxCount} more
|
|
441
|
-
<div
|
|
442
|
-
role="button"
|
|
443
|
-
tabIndex={0}
|
|
444
|
-
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
|
|
445
|
-
onClick={(e) => {
|
|
446
|
-
e.stopPropagation();
|
|
447
|
-
clearExtraOptions();
|
|
448
|
-
}}
|
|
449
|
-
>
|
|
450
|
-
<X className="h-3 w-3" />
|
|
451
|
-
</div>
|
|
452
|
-
</Badge>
|
|
453
|
-
)}
|
|
454
|
-
</>
|
|
455
|
-
) : (
|
|
405
|
+
<div className="flex flex-wrap gap-1 items-center">
|
|
406
|
+
{value.length === 0 ? (
|
|
456
407
|
<span className="text-muted-foreground">{placeholder}</span>
|
|
408
|
+
) : value.length === 1 ? (
|
|
409
|
+
<Badge variant="secondary" className="mr-1 bg-secondary hover:bg-secondary border-transparent font-normal truncate max-w-[150px]">
|
|
410
|
+
{options.find((o) => o.value === value[0])?.label}
|
|
411
|
+
</Badge>
|
|
412
|
+
) : value.length === options.length ? (
|
|
413
|
+
<Badge variant="secondary" className="mr-1 bg-secondary hover:bg-secondary border-transparent font-normal">
|
|
414
|
+
Tất cả lựa chọn
|
|
415
|
+
</Badge>
|
|
416
|
+
) : (
|
|
417
|
+
<Badge className="mr-1 bg-primary text-primary-foreground hover:bg-primary font-medium">
|
|
418
|
+
{value.length} đã chọn
|
|
419
|
+
</Badge>
|
|
457
420
|
)}
|
|
458
421
|
</div>
|
|
459
|
-
<
|
|
422
|
+
<div className="flex items-center space-x-1 ml-2">
|
|
423
|
+
{value.length > 0 && (
|
|
424
|
+
<div
|
|
425
|
+
role="button"
|
|
426
|
+
className="h-5 w-5 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
|
427
|
+
onClick={(e) => {
|
|
428
|
+
e.stopPropagation();
|
|
429
|
+
handleClearAll();
|
|
430
|
+
}}
|
|
431
|
+
>
|
|
432
|
+
<X className="h-3 w-3" />
|
|
433
|
+
</div>
|
|
434
|
+
)}
|
|
435
|
+
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
436
|
+
</div>
|
|
460
437
|
</Button>
|
|
461
438
|
|
|
462
439
|
{open && (
|
|
463
440
|
<div
|
|
464
441
|
ref={dropdownRef}
|
|
465
|
-
className="absolute z-[100] mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-
|
|
442
|
+
className="absolute z-[100] mt-1 w-full rounded-md border bg-popover/95 backdrop-blur-md text-popover-foreground shadow-lg shadow-black/5 animate-in fade-in-0 zoom-in-95 slide-in-from-top-1"
|
|
466
443
|
style={{
|
|
467
444
|
top: "100%",
|
|
468
445
|
left: 0,
|
|
@@ -516,34 +493,48 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
516
493
|
|
|
517
494
|
{/* Action Buttons */}
|
|
518
495
|
<div className="flex items-center justify-between border-b p-1">
|
|
496
|
+
<div className="flex items-center">
|
|
497
|
+
<Button
|
|
498
|
+
type="button"
|
|
499
|
+
variant="ghost"
|
|
500
|
+
size="sm"
|
|
501
|
+
className="h-8 px-2 text-xs"
|
|
502
|
+
onClick={(e) => {
|
|
503
|
+
e.stopPropagation();
|
|
504
|
+
handleSelectAll();
|
|
505
|
+
}}
|
|
506
|
+
>
|
|
507
|
+
Select All
|
|
508
|
+
</Button>
|
|
509
|
+
<Button
|
|
510
|
+
type="button"
|
|
511
|
+
variant="ghost"
|
|
512
|
+
size="sm"
|
|
513
|
+
className="h-8 px-2 text-xs"
|
|
514
|
+
onClick={(e) => {
|
|
515
|
+
e.stopPropagation();
|
|
516
|
+
handleClearAll();
|
|
517
|
+
}}
|
|
518
|
+
>
|
|
519
|
+
Clear All
|
|
520
|
+
</Button>
|
|
521
|
+
</div>
|
|
519
522
|
<Button
|
|
520
523
|
type="button"
|
|
521
524
|
variant="ghost"
|
|
522
525
|
size="sm"
|
|
523
|
-
className="h-8 px-2 text-xs"
|
|
524
|
-
onClick={(e) => {
|
|
525
|
-
e.stopPropagation();
|
|
526
|
-
handleSelectAll();
|
|
527
|
-
}}
|
|
528
|
-
>
|
|
529
|
-
Select All
|
|
530
|
-
</Button>
|
|
531
|
-
<Button
|
|
532
|
-
type="button"
|
|
533
|
-
variant="ghost"
|
|
534
|
-
size="sm"
|
|
535
|
-
className="h-8 px-2 text-xs"
|
|
526
|
+
className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
536
527
|
onClick={(e) => {
|
|
537
528
|
e.stopPropagation();
|
|
538
|
-
|
|
529
|
+
setOpen(false);
|
|
539
530
|
}}
|
|
540
531
|
>
|
|
541
|
-
|
|
532
|
+
Đóng
|
|
542
533
|
</Button>
|
|
543
534
|
</div>
|
|
544
535
|
|
|
545
536
|
{/* Options List */}
|
|
546
|
-
<
|
|
537
|
+
<div className="max-h-[250px] overflow-y-auto overflow-x-hidden">
|
|
547
538
|
{filteredOptions.length === 0 ? (
|
|
548
539
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
549
540
|
{emptyText}
|
|
@@ -560,13 +551,13 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
560
551
|
aria-selected={isSelected}
|
|
561
552
|
aria-disabled={isDisabled}
|
|
562
553
|
className={cn(
|
|
563
|
-
"relative flex select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
|
554
|
+
"relative flex select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-150",
|
|
564
555
|
isDisabled
|
|
565
556
|
? "cursor-not-allowed opacity-50"
|
|
566
|
-
: "cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
|
557
|
+
: "cursor-pointer hover:bg-accent/80 hover:text-accent-foreground",
|
|
567
558
|
isSelected &&
|
|
568
559
|
!isDisabled &&
|
|
569
|
-
"bg-accent text-accent-foreground",
|
|
560
|
+
"bg-accent/50 text-accent-foreground",
|
|
570
561
|
)}
|
|
571
562
|
onMouseDown={(e) => {
|
|
572
563
|
e.preventDefault();
|
|
@@ -582,13 +573,13 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
582
573
|
>
|
|
583
574
|
<div
|
|
584
575
|
className={cn(
|
|
585
|
-
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border
|
|
576
|
+
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border transition-all duration-200",
|
|
586
577
|
isSelected
|
|
587
|
-
? "bg-primary text-primary-foreground"
|
|
588
|
-
: "opacity-50
|
|
578
|
+
? "bg-primary border-primary text-primary-foreground"
|
|
579
|
+
: "border-input opacity-50",
|
|
589
580
|
)}
|
|
590
581
|
>
|
|
591
|
-
<Check className={cn("h-
|
|
582
|
+
<Check className={cn("h-3 w-3 transition-opacity", isSelected ? "opacity-100" : "opacity-0")} />
|
|
592
583
|
</div>
|
|
593
584
|
{option.icon && (
|
|
594
585
|
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
@@ -596,28 +587,19 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
596
587
|
<span className="flex-1 truncate">
|
|
597
588
|
{option.label}
|
|
598
589
|
</span>
|
|
590
|
+
{option.count !== undefined && (
|
|
591
|
+
<span className="ml-2 text-xs text-muted-foreground">
|
|
592
|
+
{option.count}
|
|
593
|
+
</span>
|
|
594
|
+
)}
|
|
599
595
|
</div>
|
|
600
596
|
);
|
|
601
597
|
})}
|
|
602
598
|
</div>
|
|
603
599
|
)}
|
|
604
|
-
</ScrollArea>
|
|
605
|
-
|
|
606
|
-
{/* Close Button */}
|
|
607
|
-
<div className="border-t p-2">
|
|
608
|
-
<Button
|
|
609
|
-
type="button"
|
|
610
|
-
variant="outline"
|
|
611
|
-
size="sm"
|
|
612
|
-
className="w-full h-8 text-xs"
|
|
613
|
-
onClick={(e) => {
|
|
614
|
-
e.stopPropagation();
|
|
615
|
-
setOpen(false);
|
|
616
|
-
}}
|
|
617
|
-
>
|
|
618
|
-
Close
|
|
619
|
-
</Button>
|
|
620
600
|
</div>
|
|
601
|
+
|
|
602
|
+
|
|
621
603
|
</div>
|
|
622
604
|
</div>
|
|
623
605
|
)}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
filepath = "/Users/thongle/Downloads/REPO/goerp/goerp-core/packages/core/src/ui/forms/multi-select.tsx"
|
|
4
|
+
|
|
5
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
6
|
+
content = f.read()
|
|
7
|
+
|
|
8
|
+
# 1. Thay thế Action Buttons và thêm Close icon
|
|
9
|
+
old_action_buttons = """ {/* Action Buttons */}
|
|
10
|
+
<div className="flex items-center justify-between border-b p-1">
|
|
11
|
+
<Button
|
|
12
|
+
type="button"
|
|
13
|
+
variant="ghost"
|
|
14
|
+
size="sm"
|
|
15
|
+
className="h-8 px-2 text-xs"
|
|
16
|
+
onClick={(e) => {
|
|
17
|
+
e.stopPropagation();
|
|
18
|
+
handleSelectAll();
|
|
19
|
+
}}
|
|
20
|
+
>
|
|
21
|
+
Select All
|
|
22
|
+
</Button>
|
|
23
|
+
<Button
|
|
24
|
+
type="button"
|
|
25
|
+
variant="ghost"
|
|
26
|
+
size="sm"
|
|
27
|
+
className="h-8 px-2 text-xs"
|
|
28
|
+
onClick={(e) => {
|
|
29
|
+
e.stopPropagation();
|
|
30
|
+
handleClearAll();
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
Clear All
|
|
34
|
+
</Button>
|
|
35
|
+
</div>"""
|
|
36
|
+
|
|
37
|
+
new_action_buttons = """ {/* Action Buttons */}
|
|
38
|
+
<div className="flex items-center justify-between border-b p-1">
|
|
39
|
+
<div className="flex items-center">
|
|
40
|
+
<Button
|
|
41
|
+
type="button"
|
|
42
|
+
variant="ghost"
|
|
43
|
+
size="sm"
|
|
44
|
+
className="h-8 px-2 text-xs"
|
|
45
|
+
onClick={(e) => {
|
|
46
|
+
e.stopPropagation();
|
|
47
|
+
handleSelectAll();
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
Select All
|
|
51
|
+
</Button>
|
|
52
|
+
<Button
|
|
53
|
+
type="button"
|
|
54
|
+
variant="ghost"
|
|
55
|
+
size="sm"
|
|
56
|
+
className="h-8 px-2 text-xs"
|
|
57
|
+
onClick={(e) => {
|
|
58
|
+
e.stopPropagation();
|
|
59
|
+
handleClearAll();
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
Clear All
|
|
63
|
+
</Button>
|
|
64
|
+
</div>
|
|
65
|
+
<Button
|
|
66
|
+
type="button"
|
|
67
|
+
variant="ghost"
|
|
68
|
+
size="sm"
|
|
69
|
+
className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
70
|
+
onClick={(e) => {
|
|
71
|
+
e.stopPropagation();
|
|
72
|
+
setOpen(false);
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
Đóng
|
|
76
|
+
</Button>
|
|
77
|
+
</div>"""
|
|
78
|
+
|
|
79
|
+
content = content.replace(old_action_buttons, new_action_buttons)
|
|
80
|
+
|
|
81
|
+
# 2. Xóa Close button cũ dưới cùng
|
|
82
|
+
old_close_btn = """ {/* Close Button */}
|
|
83
|
+
<div className="border-t p-2">
|
|
84
|
+
<Button
|
|
85
|
+
type="button"
|
|
86
|
+
variant="outline"
|
|
87
|
+
size="sm"
|
|
88
|
+
className="w-full h-8 text-xs"
|
|
89
|
+
onClick={(e) => {
|
|
90
|
+
e.stopPropagation();
|
|
91
|
+
setOpen(false);
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
Close
|
|
95
|
+
</Button>
|
|
96
|
+
</div>"""
|
|
97
|
+
|
|
98
|
+
content = content.replace(old_close_btn, "")
|
|
99
|
+
|
|
100
|
+
# 3. Thay ScrollArea bằng native div cuộn
|
|
101
|
+
old_scroll_start = """ {/* Options List */}
|
|
102
|
+
<ScrollArea className="max-h-[300px]">
|
|
103
|
+
{filteredOptions.length === 0 ? ("""
|
|
104
|
+
|
|
105
|
+
new_scroll_start = """ {/* Options List */}
|
|
106
|
+
<div className="max-h-[250px] overflow-y-auto overflow-x-hidden">
|
|
107
|
+
{filteredOptions.length === 0 ? ("""
|
|
108
|
+
|
|
109
|
+
content = content.replace(old_scroll_start, new_scroll_start)
|
|
110
|
+
|
|
111
|
+
# The end tag for ScrollArea
|
|
112
|
+
old_scroll_end = """ </div>
|
|
113
|
+
)}
|
|
114
|
+
</ScrollArea>"""
|
|
115
|
+
|
|
116
|
+
new_scroll_end = """ </div>
|
|
117
|
+
)}
|
|
118
|
+
</div>"""
|
|
119
|
+
|
|
120
|
+
content = content.replace(old_scroll_end, new_scroll_end)
|
|
121
|
+
|
|
122
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
123
|
+
f.write(content)
|
|
124
|
+
|
|
125
|
+
print("Updated multiselect component")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
filepath = "/Users/thongle/Downloads/REPO/goerp/goerp-core/packages/core/src/ui/forms/multi-select.tsx"
|
|
4
|
+
|
|
5
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
6
|
+
content = f.read()
|
|
7
|
+
|
|
8
|
+
old_search_logic = """ // Filter options based on search value
|
|
9
|
+
const filteredOptions = useMemo(() => {
|
|
10
|
+
if (!searchValue.trim()) {
|
|
11
|
+
return options;
|
|
12
|
+
}
|
|
13
|
+
const searchLower = searchValue.toLowerCase();
|
|
14
|
+
return options.filter(
|
|
15
|
+
(option) =>
|
|
16
|
+
option.label.toLowerCase().includes(searchLower) ||
|
|
17
|
+
String(option.value).toLowerCase().includes(searchLower),
|
|
18
|
+
);
|
|
19
|
+
}, [options, searchValue]);"""
|
|
20
|
+
|
|
21
|
+
new_search_logic = """ // Filter options based on search value
|
|
22
|
+
const filteredOptions = useMemo(() => {
|
|
23
|
+
if (!searchValue.trim()) {
|
|
24
|
+
return options;
|
|
25
|
+
}
|
|
26
|
+
const removeAccents = (str: string) => {
|
|
27
|
+
return str ? str.normalize('NFD').replace(/[\\u0300-\\u036f]/g, '') : '';
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const searchLower = removeAccents(searchValue.toLowerCase());
|
|
31
|
+
|
|
32
|
+
return options.filter(
|
|
33
|
+
(option) => {
|
|
34
|
+
const labelLower = option.label ? removeAccents(option.label.toLowerCase()) : '';
|
|
35
|
+
const valueLower = option.value !== undefined && option.value !== null
|
|
36
|
+
? removeAccents(String(option.value).toLowerCase())
|
|
37
|
+
: '';
|
|
38
|
+
return labelLower.includes(searchLower) || valueLower.includes(searchLower);
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
}, [options, searchValue]);"""
|
|
42
|
+
|
|
43
|
+
content = content.replace(old_search_logic, new_search_logic)
|
|
44
|
+
|
|
45
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
46
|
+
f.write(content)
|
|
47
|
+
|
|
48
|
+
print("Updated multiselect search logic")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
filepath = "/Users/thongle/Downloads/REPO/goerp/goerp-core/packages/core/src/ui/forms/multi-select.tsx"
|
|
4
|
+
|
|
5
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
6
|
+
content = f.read()
|
|
7
|
+
|
|
8
|
+
# 1. Update MultiSelectOption interface
|
|
9
|
+
old_interface = """ /** Optional icon component to display alongside the option */
|
|
10
|
+
icon?: React.ComponentType<{ className?: string }>;
|
|
11
|
+
}"""
|
|
12
|
+
|
|
13
|
+
new_interface = """ /** Optional icon component to display alongside the option */
|
|
14
|
+
icon?: React.ComponentType<{ className?: string }>;
|
|
15
|
+
/** Optional count to display on the right side of the option */
|
|
16
|
+
count?: number;
|
|
17
|
+
}"""
|
|
18
|
+
|
|
19
|
+
content = content.replace(old_interface, new_interface)
|
|
20
|
+
|
|
21
|
+
# 2. Update trigger button (Compact Badge & Clear Icon)
|
|
22
|
+
# We need to replace the content of `<div className="flex flex-wrap gap-1">`
|
|
23
|
+
# and the ChevronsUpDown area.
|
|
24
|
+
old_trigger_area = r'<div className="flex flex-wrap gap-1">[\s\S]*?<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />'
|
|
25
|
+
|
|
26
|
+
new_trigger_area = """<div className="flex flex-wrap gap-1 items-center">
|
|
27
|
+
{value.length === 0 ? (
|
|
28
|
+
<span className="text-muted-foreground">{placeholder}</span>
|
|
29
|
+
) : value.length === 1 ? (
|
|
30
|
+
<Badge variant="secondary" className="mr-1 bg-secondary hover:bg-secondary border-transparent font-normal truncate max-w-[150px]">
|
|
31
|
+
{options.find((o) => o.value === value[0])?.label}
|
|
32
|
+
</Badge>
|
|
33
|
+
) : value.length === options.length ? (
|
|
34
|
+
<Badge variant="secondary" className="mr-1 bg-secondary hover:bg-secondary border-transparent font-normal">
|
|
35
|
+
Tất cả lựa chọn
|
|
36
|
+
</Badge>
|
|
37
|
+
) : (
|
|
38
|
+
<Badge className="mr-1 bg-primary text-primary-foreground hover:bg-primary font-medium">
|
|
39
|
+
{value.length} đã chọn
|
|
40
|
+
</Badge>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
<div className="flex items-center space-x-1 ml-2">
|
|
44
|
+
{value.length > 0 && (
|
|
45
|
+
<div
|
|
46
|
+
role="button"
|
|
47
|
+
className="h-5 w-5 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
|
48
|
+
onClick={(e) => {
|
|
49
|
+
e.stopPropagation();
|
|
50
|
+
handleClearAll();
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<X className="h-3 w-3" />
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
57
|
+
</div>"""
|
|
58
|
+
|
|
59
|
+
content = re.sub(old_trigger_area, new_trigger_area, content)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# 3. Update Popover Container for glassmorphism and animation
|
|
63
|
+
old_popover = """className="absolute z-[100] mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md\""""
|
|
64
|
+
new_popover = """className="absolute z-[100] mt-1 w-full rounded-md border bg-popover/95 backdrop-blur-md text-popover-foreground shadow-lg shadow-black/5 animate-in fade-in-0 zoom-in-95 slide-in-from-top-1\""""
|
|
65
|
+
content = content.replace(old_popover, new_popover)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# 4. Update Option Checkbox and Count
|
|
69
|
+
old_option_render = r"""<div\s+className=\{cn\(\s*"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",\s*isSelected\s*\?\s*"bg-primary text-primary-foreground"\s*:\s*"opacity-50 \[\&_svg\]:invisible",\s*\)\}\s*>\s*<Check className=\{cn\("h-4 w-4"\)\} />\s*</div>\s*\{option\.icon && \(\s*<option\.icon className="mr-2 h-4 w-4 text-muted-foreground" />\s*\)\}\s*<span className="flex-1 truncate">\s*\{option\.label\}\s*</span>"""
|
|
70
|
+
|
|
71
|
+
new_option_render = """<div
|
|
72
|
+
className={cn(
|
|
73
|
+
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border transition-all duration-200",
|
|
74
|
+
isSelected
|
|
75
|
+
? "bg-primary border-primary text-primary-foreground"
|
|
76
|
+
: "border-input opacity-50",
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
<Check className={cn("h-3 w-3 transition-opacity", isSelected ? "opacity-100" : "opacity-0")} />
|
|
80
|
+
</div>
|
|
81
|
+
{option.icon && (
|
|
82
|
+
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
83
|
+
)}
|
|
84
|
+
<span className="flex-1 truncate">
|
|
85
|
+
{option.label}
|
|
86
|
+
</span>
|
|
87
|
+
{option.count !== undefined && (
|
|
88
|
+
<span className="ml-2 text-xs text-muted-foreground">
|
|
89
|
+
{option.count}
|
|
90
|
+
</span>
|
|
91
|
+
)}"""
|
|
92
|
+
|
|
93
|
+
content = re.sub(old_option_render, new_option_render, content)
|
|
94
|
+
|
|
95
|
+
# 5. Option Hover Animation
|
|
96
|
+
old_option_wrapper = """className={cn(
|
|
97
|
+
"relative flex select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
|
98
|
+
isDisabled
|
|
99
|
+
? "cursor-not-allowed opacity-50"
|
|
100
|
+
: "cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
|
101
|
+
isSelected &&
|
|
102
|
+
!isDisabled &&
|
|
103
|
+
"bg-accent text-accent-foreground",
|
|
104
|
+
)}"""
|
|
105
|
+
new_option_wrapper = """className={cn(
|
|
106
|
+
"relative flex select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-150",
|
|
107
|
+
isDisabled
|
|
108
|
+
? "cursor-not-allowed opacity-50"
|
|
109
|
+
: "cursor-pointer hover:bg-accent/80 hover:text-accent-foreground",
|
|
110
|
+
isSelected &&
|
|
111
|
+
!isDisabled &&
|
|
112
|
+
"bg-accent/50 text-accent-foreground",
|
|
113
|
+
)}"""
|
|
114
|
+
content = content.replace(old_option_wrapper, new_option_wrapper)
|
|
115
|
+
|
|
116
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
117
|
+
f.write(content)
|
|
118
|
+
|
|
119
|
+
print("UI Upgrade Successful")
|
package/src/ui/index.tsx
CHANGED
|
@@ -144,14 +144,14 @@ export function Combobox({
|
|
|
144
144
|
role="combobox"
|
|
145
145
|
aria-expanded={open}
|
|
146
146
|
disabled={disabled}
|
|
147
|
-
className={cn("w-full justify-between", className)}
|
|
147
|
+
className={cn("w-full justify-between h-auto min-h-9 text-left whitespace-normal gap-2", className)}
|
|
148
148
|
id={id}
|
|
149
149
|
onClick={(e) => {
|
|
150
150
|
e.stopPropagation();
|
|
151
151
|
setOpen(!open);
|
|
152
152
|
}}
|
|
153
153
|
>
|
|
154
|
-
<span className="
|
|
154
|
+
<span className="break-words min-w-0">
|
|
155
155
|
{selectedOption ? selectedOption.label : placeholder}
|
|
156
156
|
</span>
|
|
157
157
|
<div className="ml-2 flex items-center gap-1 shrink-0">
|
|
@@ -37,7 +37,7 @@ export function SelectTrigger({
|
|
|
37
37
|
<SelectPrimitive.Trigger
|
|
38
38
|
data-slot="select-trigger"
|
|
39
39
|
className={cn(
|
|
40
|
-
"cursor-pointer flex h-9 w-full items-center justify-between
|
|
40
|
+
"cursor-pointer flex min-h-9 h-auto w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm text-left ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_span]:line-clamp-none [&_span]:whitespace-normal [&_span]:break-words [&_span]:min-w-0",
|
|
41
41
|
className,
|
|
42
42
|
)}
|
|
43
43
|
{...props}
|