meyi-vault-client-dev 1.0.1

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,275 @@
1
+ // components/searchable-multi-select.tsx
2
+ import { useState, useEffect, useRef, useCallback } from 'react'
3
+ import { Check, ChevronsUpDown, Loader2, X } from 'lucide-react'
4
+ import { cn } from '@/lib/utils'
5
+ import { Button } from '@/components/ui/button'
6
+ import {
7
+ Command,
8
+ CommandEmpty,
9
+ CommandGroup,
10
+ CommandInput,
11
+ CommandItem,
12
+ CommandList,
13
+ } from '@/components/ui/command'
14
+ import {
15
+ Popover,
16
+ PopoverContent,
17
+ PopoverTrigger,
18
+ } from '@/components/ui/popover'
19
+ import { Badge } from '@/components/ui/badge'
20
+ import { ScrollArea } from '@/components/ui/scroll-area'
21
+
22
+ export interface FieldMapping {
23
+ value: string // The field name for the value (e.g., 'id', 'user_id')
24
+ label: string // The field name for the label (e.g., 'name', 'username', 'full_name')
25
+ }
26
+
27
+ export interface SearchableMultiSelectProps {
28
+ value: string[]
29
+ onChange: (value: string[]) => void
30
+ fetchData: (search: string, page: number) => Promise<{
31
+ data: any[]
32
+ hasMore: boolean
33
+ }>
34
+ fieldMapping: FieldMapping
35
+ placeholder?: string
36
+ disabled?: boolean
37
+ className?: string
38
+ emptyText?: string
39
+ }
40
+
41
+ export function SearchableMultiSelect({
42
+ value = [],
43
+ onChange,
44
+ fetchData,
45
+ fieldMapping,
46
+ placeholder = 'Select items...',
47
+ disabled = false,
48
+ className,
49
+ emptyText = 'No items found.',
50
+ }: SearchableMultiSelectProps) {
51
+ const [open, setOpen] = useState(false)
52
+ const [search, setSearch] = useState('')
53
+ const [options, setOptions] = useState<any[]>([])
54
+ const [selectedItems, setSelectedItems] = useState<any[]>([])
55
+ const [loading, setLoading] = useState(false)
56
+ const [page, setPage] = useState(1)
57
+ const [hasMore, setHasMore] = useState(true)
58
+ const observerTarget = useRef<HTMLDivElement>(null)
59
+
60
+ // Fetch data
61
+ const loadData = useCallback(
62
+ async (searchTerm: string, pageNum: number, reset: boolean = false) => {
63
+ setLoading(true)
64
+ try {
65
+ const result = await fetchData(searchTerm, pageNum)
66
+ setOptions((prev) => (reset ? result.data : [...prev, ...result.data]))
67
+ setHasMore(result.hasMore)
68
+ } catch (error) {
69
+ console.error('Error fetching data:', error)
70
+ } finally {
71
+ setLoading(false)
72
+ }
73
+ },
74
+ [fetchData]
75
+ )
76
+
77
+ // Initial load and search
78
+ useEffect(() => {
79
+ const timeoutId = setTimeout(() => {
80
+ setPage(1)
81
+ loadData(search, 1, true)
82
+ }, 300)
83
+ return () => clearTimeout(timeoutId)
84
+ }, [search, loadData])
85
+
86
+ // Intersection observer for pagination
87
+ useEffect(() => {
88
+ const observer = new IntersectionObserver(
89
+ (entries) => {
90
+ if (entries[0].isIntersecting && hasMore && !loading) {
91
+ const nextPage = page + 1
92
+ setPage(nextPage)
93
+ loadData(search, nextPage, false)
94
+ }
95
+ },
96
+ { threshold: 1.0 }
97
+ )
98
+
99
+ const currentTarget = observerTarget.current
100
+ if (currentTarget) {
101
+ observer.observe(currentTarget)
102
+ }
103
+
104
+ return () => {
105
+ if (currentTarget) {
106
+ observer.unobserve(currentTarget)
107
+ }
108
+ }
109
+ }, [hasMore, loading, page, search, loadData])
110
+
111
+ // Load selected items details
112
+ useEffect(() => {
113
+ const loadSelectedDetails = async () => {
114
+ if (value.length > 0 && options.length > 0) {
115
+ const selected = options.filter((opt) =>
116
+ value.includes(opt[fieldMapping.value])
117
+ )
118
+ setSelectedItems(selected)
119
+ } else if (value.length === 0) {
120
+ setSelectedItems([])
121
+ }
122
+ }
123
+ loadSelectedDetails()
124
+ }, [value, options, fieldMapping.value])
125
+
126
+ const handleSelect = (item: any) => {
127
+ const itemValue = item[fieldMapping.value]
128
+ const newValue = value.includes(itemValue)
129
+ ? value.filter((v) => v !== itemValue)
130
+ : [...value, itemValue]
131
+
132
+ onChange(newValue)
133
+ }
134
+
135
+ const handleRemove = (itemValue: string, e?: React.MouseEvent) => {
136
+ e?.stopPropagation()
137
+ onChange(value.filter((v) => v !== itemValue))
138
+ }
139
+
140
+ const getInitials = (name: string) => {
141
+ if (!name) return 'U'
142
+ return name
143
+ .split(' ')
144
+ .map((n) => n[0])
145
+ .join('')
146
+ .toUpperCase()
147
+ .slice(0, 2)
148
+ }
149
+
150
+ const getSelectedItem = (itemValue: string) => {
151
+ return selectedItems.find((item) => item[fieldMapping.value] === itemValue)
152
+ }
153
+
154
+ const handleClearSearch = () => {
155
+ setSearch('')
156
+ }
157
+
158
+ return (
159
+ <Popover open={open} onOpenChange={setOpen}>
160
+ <PopoverTrigger asChild>
161
+ <Button
162
+ variant="outline"
163
+ role="combobox"
164
+ aria-expanded={open}
165
+ disabled={disabled}
166
+ className={cn(
167
+ 'w-full justify-between h-auto min-h-10 px-3 py-2',
168
+ className
169
+ )}
170
+ >
171
+ <div className="flex flex-wrap gap-1.5 flex-1 max-h-32 overflow-y-auto py-1 scrollbar-hide">
172
+ {value.length === 0 ? (
173
+ <span className="text-muted-foreground">{placeholder}</span>
174
+ ) : (
175
+ value.map((itemValue) => {
176
+ const item = getSelectedItem(itemValue)
177
+ const displayName = item
178
+ ? item[fieldMapping.label]
179
+ : itemValue
180
+ return (
181
+ <Badge
182
+ key={itemValue}
183
+ variant="secondary"
184
+ className="pl-2 pr-1 py-1 gap-1.5 rounded-full hover:bg-secondary/80 border-primary/10"
185
+ >
186
+ <div className="flex items-center gap-1.5">
187
+ <div className="h-5 w-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-[10px] font-bold">
188
+ {getInitials(displayName)}
189
+ </div>
190
+ <span className="text-xs max-w-[120px] truncate font-medium">
191
+ {displayName}
192
+ </span>
193
+ <button
194
+ type="button"
195
+ onClick={(e) => handleRemove(itemValue, e)}
196
+ className="ml-0.5 rounded-full hover:bg-destructive/10 hover:text-destructive p-0.5 transition-colors"
197
+ >
198
+ <X className="h-3 w-3" />
199
+ </button>
200
+ </div>
201
+ </Badge>
202
+ )
203
+ })
204
+ )}
205
+ </div>
206
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
207
+ </Button>
208
+ </PopoverTrigger>
209
+ <PopoverContent className="w-full p-0" align="start">
210
+ <Command shouldFilter={false}>
211
+ <div className="relative">
212
+ <CommandInput
213
+ placeholder="Search..."
214
+ value={search}
215
+ onValueChange={setSearch}
216
+ className="pr-8"
217
+ />
218
+ {search && (
219
+ <Button
220
+ type="button"
221
+ variant="ghost"
222
+ size="sm"
223
+ className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0 hover:bg-muted"
224
+ onClick={handleClearSearch}
225
+ >
226
+ <X className="h-3 w-3" />
227
+ </Button>
228
+ )}
229
+ </div>
230
+ <CommandList>
231
+ <CommandEmpty>{emptyText}</CommandEmpty>
232
+ <CommandGroup>
233
+ <ScrollArea className="h-72">
234
+ {Array.isArray(options)
235
+ ? options.map((item) => {
236
+ const itemValue = item[fieldMapping.value]
237
+ const itemLabel = item[fieldMapping.label]
238
+ const isSelected = value.includes(itemValue)
239
+
240
+ return (
241
+ <CommandItem
242
+ key={itemValue}
243
+ onSelect={() => handleSelect(item)}
244
+ className="cursor-pointer"
245
+ >
246
+ <div className="flex items-center gap-2 flex-1">
247
+ <div className="h-7 w-7 rounded-full bg-muted flex items-center justify-center text-xs font-medium">
248
+ {getInitials(itemLabel)}
249
+ </div>
250
+ <span className="flex-1">{itemLabel}</span>
251
+ <Check
252
+ className={cn(
253
+ 'h-4 w-4',
254
+ isSelected ? 'opacity-100' : 'opacity-0'
255
+ )}
256
+ />
257
+ </div>
258
+ </CommandItem>
259
+ )
260
+ })
261
+ : null}
262
+ {loading && (
263
+ <div className="flex items-center justify-center py-4">
264
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
265
+ </div>
266
+ )}
267
+ <div ref={observerTarget} className="h-2" />
268
+ </ScrollArea>
269
+ </CommandGroup>
270
+ </CommandList>
271
+ </Command>
272
+ </PopoverContent>
273
+ </Popover>
274
+ )
275
+ }
@@ -0,0 +1,46 @@
1
+ import * as React from 'react'
2
+ import { Slot } from '@radix-ui/react-slot'
3
+ import { cva, type VariantProps } from 'class-variance-authority'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ const badgeVariants = cva(
7
+ 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
8
+ {
9
+ variants: {
10
+ variant: {
11
+ success: 'bg-green-500 text-white',
12
+ default:
13
+ 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
14
+ secondary:
15
+ 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
16
+ destructive:
17
+ 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
18
+ outline:
19
+ 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ variant: 'default',
24
+ },
25
+ }
26
+ )
27
+
28
+ function Badge({
29
+ className,
30
+ variant,
31
+ asChild = false,
32
+ ...props
33
+ }: React.ComponentProps<'span'> &
34
+ VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
35
+ const Comp = asChild ? Slot : 'span'
36
+
37
+ return (
38
+ <Comp
39
+ data-slot='badge'
40
+ className={cn(badgeVariants({ variant }), className)}
41
+ {...props}
42
+ />
43
+ )
44
+ }
45
+
46
+ export { Badge, badgeVariants }
@@ -0,0 +1,58 @@
1
+ import * as React from 'react'
2
+ import { Slot } from '@radix-ui/react-slot'
3
+ import { cva, type VariantProps } from 'class-variance-authority'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ const buttonVariants = cva(
7
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default:
12
+ 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
13
+ destructive:
14
+ 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
15
+ outline:
16
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
17
+ secondary:
18
+ 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
19
+ ghost:
20
+ 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
21
+ link: 'text-primary underline-offset-4 hover:underline',
22
+ },
23
+ size: {
24
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
25
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
26
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
27
+ icon: 'size-9',
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ variant: 'default',
32
+ size: 'default',
33
+ },
34
+ }
35
+ )
36
+
37
+ function Button({
38
+ className,
39
+ variant,
40
+ size,
41
+ asChild = false,
42
+ ...props
43
+ }: React.ComponentProps<'button'> &
44
+ VariantProps<typeof buttonVariants> & {
45
+ asChild?: boolean
46
+ }) {
47
+ const Comp = asChild ? Slot : 'button'
48
+
49
+ return (
50
+ <Comp
51
+ data-slot='button'
52
+ className={cn(buttonVariants({ variant, size, className }))}
53
+ {...props}
54
+ />
55
+ )
56
+ }
57
+
58
+ export { Button, buttonVariants }
@@ -0,0 +1,210 @@
1
+ import * as React from 'react'
2
+ import {
3
+ ChevronDownIcon,
4
+ ChevronLeftIcon,
5
+ ChevronRightIcon,
6
+ } from 'lucide-react'
7
+ import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
8
+ import { cn } from '@/lib/utils'
9
+ import { Button, buttonVariants } from '@/components/ui/button'
10
+
11
+ function Calendar({
12
+ className,
13
+ classNames,
14
+ showOutsideDays = true,
15
+ captionLayout = 'label',
16
+ buttonVariant = 'ghost',
17
+ formatters,
18
+ components,
19
+ ...props
20
+ }: React.ComponentProps<typeof DayPicker> & {
21
+ buttonVariant?: React.ComponentProps<typeof Button>['variant']
22
+ }) {
23
+ const defaultClassNames = getDefaultClassNames()
24
+
25
+ return (
26
+ <DayPicker
27
+ showOutsideDays={showOutsideDays}
28
+ className={cn(
29
+ 'bg-card group/calendar p-3',
30
+ String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
31
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
32
+ className
33
+ )}
34
+ captionLayout={captionLayout}
35
+ formatters={{
36
+ formatMonthDropdown: (date) =>
37
+ date.toLocaleString('default', { month: 'short' }),
38
+ ...formatters,
39
+ }}
40
+ classNames={{
41
+ root: cn('w-fit', defaultClassNames.root),
42
+ months: cn(
43
+ 'flex gap-4 flex-col md:flex-row relative',
44
+ defaultClassNames.months
45
+ ),
46
+ month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
47
+ nav: cn(
48
+ 'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
49
+ defaultClassNames.nav
50
+ ),
51
+ button_previous: cn(
52
+ buttonVariants({ variant: buttonVariant }),
53
+ 'h-8 w-8 aria-disabled:opacity-50 p-0 select-none',
54
+ defaultClassNames.button_previous
55
+ ),
56
+ button_next: cn(
57
+ buttonVariants({ variant: buttonVariant }),
58
+ 'h-8 w-8 aria-disabled:opacity-50 p-0 select-none',
59
+ defaultClassNames.button_next
60
+ ),
61
+ month_caption: cn(
62
+ 'flex items-center justify-center h-8 w-full px-8',
63
+ defaultClassNames.month_caption
64
+ ),
65
+ dropdowns: cn(
66
+ 'w-full flex items-center text-sm font-medium justify-center h-8 gap-1.5',
67
+ defaultClassNames.dropdowns
68
+ ),
69
+ dropdown_root: cn(
70
+ 'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
71
+ defaultClassNames.dropdown_root
72
+ ),
73
+ dropdown: cn(
74
+ 'absolute bg-popover inset-0 opacity-0',
75
+ defaultClassNames.dropdown
76
+ ),
77
+ caption_label: cn(
78
+ 'select-none font-medium',
79
+ captionLayout === 'label'
80
+ ? 'text-sm'
81
+ : 'rounded-md ps-2 pe-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
82
+ defaultClassNames.caption_label
83
+ ),
84
+ month_grid: 'w-full border-collapse',
85
+ weekdays: cn('flex gap-1', defaultClassNames.weekdays),
86
+ weekday: cn(
87
+ 'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none text-center w-8',
88
+ defaultClassNames.weekday
89
+ ),
90
+ week: cn('flex w-full mt-2 gap-1', defaultClassNames.week),
91
+ week_number_header: cn(
92
+ 'select-none w-8 text-center',
93
+ defaultClassNames.week_number_header
94
+ ),
95
+ week_number: cn(
96
+ 'text-[0.8rem] select-none text-muted-foreground w-8 text-center',
97
+ defaultClassNames.week_number
98
+ ),
99
+ day: cn(
100
+ 'relative w-8 h-8 p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
101
+ defaultClassNames.day
102
+ ),
103
+ range_start: cn(
104
+ 'rounded-l-md bg-accent',
105
+ defaultClassNames.range_start
106
+ ),
107
+ range_middle: cn('rounded-none', defaultClassNames.range_middle),
108
+ range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
109
+ today: cn(
110
+ 'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
111
+ defaultClassNames.today
112
+ ),
113
+ outside: cn(
114
+ 'text-muted-foreground aria-selected:text-muted-foreground',
115
+ defaultClassNames.outside
116
+ ),
117
+ disabled: cn(
118
+ 'text-muted-foreground opacity-50',
119
+ defaultClassNames.disabled
120
+ ),
121
+ hidden: cn('invisible', defaultClassNames.hidden),
122
+ ...classNames,
123
+ }}
124
+ components={{
125
+ Root: ({ className, rootRef, ...props }) => {
126
+ return (
127
+ <div
128
+ data-slot='calendar'
129
+ ref={rootRef}
130
+ className={cn(className)}
131
+ {...props}
132
+ />
133
+ )
134
+ },
135
+ Chevron: ({ className, orientation, ...props }) => {
136
+ if (orientation === 'left') {
137
+ return (
138
+ <ChevronLeftIcon className={cn('size-4', className)} {...props} />
139
+ )
140
+ }
141
+
142
+ if (orientation === 'right') {
143
+ return (
144
+ <ChevronRightIcon
145
+ className={cn('size-4', className)}
146
+ {...props}
147
+ />
148
+ )
149
+ }
150
+
151
+ return (
152
+ <ChevronDownIcon className={cn('size-4', className)} {...props} />
153
+ )
154
+ },
155
+ DayButton: CalendarDayButton,
156
+ WeekNumber: ({ children, ...props }) => {
157
+ return (
158
+ <td {...props}>
159
+ <div className='flex size-(--cell-size) items-center justify-center text-center'>
160
+ {children}
161
+ </div>
162
+ </td>
163
+ )
164
+ },
165
+ ...components,
166
+ }}
167
+ {...props}
168
+ />
169
+ )
170
+ }
171
+
172
+ function CalendarDayButton({
173
+ className,
174
+ day,
175
+ modifiers,
176
+ ...props
177
+ }: React.ComponentProps<typeof DayButton>) {
178
+ const defaultClassNames = getDefaultClassNames()
179
+
180
+ const ref = React.useRef<HTMLButtonElement>(null)
181
+ React.useEffect(() => {
182
+ if (modifiers.focused) ref.current?.focus()
183
+ }, [modifiers.focused])
184
+
185
+ return (
186
+ <Button
187
+ ref={ref}
188
+ variant='ghost'
189
+ size='icon'
190
+ data-day={day.date.toLocaleDateString()}
191
+ data-selected-single={
192
+ modifiers.selected &&
193
+ !modifiers.range_start &&
194
+ !modifiers.range_end &&
195
+ !modifiers.range_middle
196
+ }
197
+ data-range-start={modifiers.range_start}
198
+ data-range-end={modifiers.range_end}
199
+ data-range-middle={modifiers.range_middle}
200
+ className={cn(
201
+ 'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square h-8 w-8 min-w-8 flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
202
+ defaultClassNames.day,
203
+ className
204
+ )}
205
+ {...props}
206
+ />
207
+ )
208
+ }
209
+
210
+ export { Calendar, CalendarDayButton }