@tuturuuu/ui 0.0.4

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.
Files changed (104) hide show
  1. package/.checksum +1 -0
  2. package/README.md +46 -0
  3. package/components.json +20 -0
  4. package/eslint.config.mjs +20 -0
  5. package/jsr.json +10 -0
  6. package/package.json +120 -0
  7. package/postcss.config.mjs +8 -0
  8. package/rollup.config.js +40 -0
  9. package/src/components/ui/accordion.tsx +70 -0
  10. package/src/components/ui/alert-dialog.tsx +156 -0
  11. package/src/components/ui/alert.tsx +58 -0
  12. package/src/components/ui/aspect-ratio.tsx +11 -0
  13. package/src/components/ui/avatar.tsx +52 -0
  14. package/src/components/ui/badge.tsx +49 -0
  15. package/src/components/ui/breadcrumb.tsx +108 -0
  16. package/src/components/ui/button.tsx +61 -0
  17. package/src/components/ui/calendar.tsx +212 -0
  18. package/src/components/ui/card.tsx +74 -0
  19. package/src/components/ui/carousel.tsx +240 -0
  20. package/src/components/ui/chart.tsx +365 -0
  21. package/src/components/ui/checkbox.tsx +31 -0
  22. package/src/components/ui/codeblock.tsx +161 -0
  23. package/src/components/ui/collapsible.tsx +33 -0
  24. package/src/components/ui/color-picker.tsx +143 -0
  25. package/src/components/ui/command.tsx +176 -0
  26. package/src/components/ui/context-menu.tsx +251 -0
  27. package/src/components/ui/custom/autosize-textarea.tsx +111 -0
  28. package/src/components/ui/custom/calendar/core.tsx +61 -0
  29. package/src/components/ui/custom/calendar/day-cell.tsx +74 -0
  30. package/src/components/ui/custom/calendar/month-header.tsx +59 -0
  31. package/src/components/ui/custom/calendar/month-view.tsx +110 -0
  32. package/src/components/ui/custom/calendar/utils.ts +76 -0
  33. package/src/components/ui/custom/calendar/year-calendar.tsx +64 -0
  34. package/src/components/ui/custom/calendar/year-view.tsx +58 -0
  35. package/src/components/ui/custom/combobox.tsx +197 -0
  36. package/src/components/ui/custom/common-footer.tsx +215 -0
  37. package/src/components/ui/custom/compared-date-range-picker.tsx +561 -0
  38. package/src/components/ui/custom/date-input.tsx +279 -0
  39. package/src/components/ui/custom/empty-card.tsx +39 -0
  40. package/src/components/ui/custom/feature-summary.tsx +135 -0
  41. package/src/components/ui/custom/file-uploader.tsx +349 -0
  42. package/src/components/ui/custom/input-field.tsx +29 -0
  43. package/src/components/ui/custom/loading-indicator.tsx +28 -0
  44. package/src/components/ui/custom/modifiable-dialog-trigger.tsx +83 -0
  45. package/src/components/ui/custom/month-picker.tsx +157 -0
  46. package/src/components/ui/custom/report-preview.tsx +175 -0
  47. package/src/components/ui/custom/search-bar.tsx +56 -0
  48. package/src/components/ui/custom/select-field.tsx +78 -0
  49. package/src/components/ui/custom/tables/data-table-column-header.tsx +72 -0
  50. package/src/components/ui/custom/tables/data-table-create-button.tsx +31 -0
  51. package/src/components/ui/custom/tables/data-table-faceted-filter.tsx +142 -0
  52. package/src/components/ui/custom/tables/data-table-pagination.tsx +243 -0
  53. package/src/components/ui/custom/tables/data-table-refresh-button.tsx +45 -0
  54. package/src/components/ui/custom/tables/data-table-toolbar.tsx +133 -0
  55. package/src/components/ui/custom/tables/data-table-view-options.tsx +112 -0
  56. package/src/components/ui/custom/tables/data-table.tsx +228 -0
  57. package/src/components/ui/custom/uploaded-files-card.tsx +50 -0
  58. package/src/components/ui/dialog.tsx +137 -0
  59. package/src/components/ui/drawer.tsx +131 -0
  60. package/src/components/ui/dropdown-menu.tsx +256 -0
  61. package/src/components/ui/form.tsx +167 -0
  62. package/src/components/ui/hover-card.tsx +41 -0
  63. package/src/components/ui/icons.tsx +506 -0
  64. package/src/components/ui/input-otp.tsx +78 -0
  65. package/src/components/ui/input.tsx +18 -0
  66. package/src/components/ui/label.tsx +23 -0
  67. package/src/components/ui/markdown.tsx +7 -0
  68. package/src/components/ui/menubar.tsx +275 -0
  69. package/src/components/ui/navigation-menu.tsx +169 -0
  70. package/src/components/ui/pagination.tsx +126 -0
  71. package/src/components/ui/popover.tsx +47 -0
  72. package/src/components/ui/progress.tsx +30 -0
  73. package/src/components/ui/radio-group.tsx +44 -0
  74. package/src/components/ui/resizable.tsx +55 -0
  75. package/src/components/ui/scroll-area.tsx +57 -0
  76. package/src/components/ui/select.tsx +180 -0
  77. package/src/components/ui/separator.tsx +27 -0
  78. package/src/components/ui/sheet.tsx +138 -0
  79. package/src/components/ui/sidebar.tsx +734 -0
  80. package/src/components/ui/skeleton.tsx +13 -0
  81. package/src/components/ui/slider.tsx +62 -0
  82. package/src/components/ui/sonner.tsx +29 -0
  83. package/src/components/ui/switch.tsx +30 -0
  84. package/src/components/ui/table.tsx +112 -0
  85. package/src/components/ui/tabs.tsx +68 -0
  86. package/src/components/ui/tag-input.tsx +141 -0
  87. package/src/components/ui/textarea.tsx +17 -0
  88. package/src/components/ui/time-picker-input.tsx +117 -0
  89. package/src/components/ui/time-picker-utils.tsx +146 -0
  90. package/src/components/ui/toast.tsx +128 -0
  91. package/src/components/ui/toaster.tsx +35 -0
  92. package/src/components/ui/toggle-group.tsx +72 -0
  93. package/src/components/ui/toggle.tsx +46 -0
  94. package/src/components/ui/tooltip.tsx +60 -0
  95. package/src/globals.css +252 -0
  96. package/src/hooks/use-callback-ref.ts +28 -0
  97. package/src/hooks/use-controllable-state.ts +68 -0
  98. package/src/hooks/use-copy-to-clipboard.ts +46 -0
  99. package/src/hooks/use-form.ts +23 -0
  100. package/src/hooks/use-forwarded-ref.ts +17 -0
  101. package/src/hooks/use-mobile.tsx +21 -0
  102. package/src/hooks/use-toast.ts +191 -0
  103. package/src/resolvers.ts +3 -0
  104. package/tsconfig.json +17 -0
@@ -0,0 +1,349 @@
1
+ 'use client';
2
+
3
+ import { useControllableState } from '../../../hooks/use-controllable-state';
4
+ import { Button } from '../button';
5
+ import { ScrollArea } from '../scroll-area';
6
+ import { Separator } from '../separator';
7
+ import { cn, formatBytes } from '@tuturuuu/utils/format';
8
+ import { File, FileText, Upload, X } from 'lucide-react';
9
+ import { HTMLAttributes, useCallback, useState } from 'react';
10
+ import Dropzone, {
11
+ type DropzoneProps,
12
+ type FileRejection,
13
+ } from 'react-dropzone';
14
+ import { toast } from 'sonner';
15
+
16
+ export interface StatedFile {
17
+ rawFile: File;
18
+ url: string;
19
+ status: 'pending' | 'uploading' | 'uploaded' | 'error';
20
+ }
21
+
22
+ interface FileUploaderProps extends HTMLAttributes<HTMLDivElement> {
23
+ /**
24
+ * Value of the uploader.
25
+ * @type StatedFile[]
26
+ * @default undefined
27
+ * @example value={files}
28
+ */
29
+ value?: StatedFile[];
30
+
31
+ /**
32
+ * Function to be called when the value changes.
33
+ * @type (files: StatedFile[]) => void
34
+ * @default undefined
35
+ * @example onValueChange={(files) => setFiles(files)}
36
+ */
37
+ // eslint-disable-next-line no-unused-vars
38
+ onValueChange?: (files: StatedFile[]) => void;
39
+
40
+ /**
41
+ * Function to be called when files are uploaded.
42
+ * @type (files: StatedFile[]) => Promise<void>
43
+ * @default undefined
44
+ * @example onUpload={(files) => uploadFiles(files)}
45
+ */
46
+ // eslint-disable-next-line no-unused-vars
47
+ onUpload?: (files: StatedFile[]) => Promise<void>;
48
+
49
+ /**
50
+ * Accepted file types for the uploader.
51
+ * @type { [key: string]: string[]}
52
+ * @default
53
+ * ```ts
54
+ * { "image/*": [] }
55
+ * ```
56
+ * @example accept={["image/png", "image/jpeg"]}
57
+ */
58
+ accept?: DropzoneProps['accept'];
59
+
60
+ /**
61
+ * Maximum file size for the uploader.
62
+ * @type number | undefined
63
+ * @default 1024 * 1024 * 2 // 2MB
64
+ * @example maxSize={1024 * 1024 * 2} // 2MB
65
+ */
66
+ maxSize?: DropzoneProps['maxSize'];
67
+
68
+ /**
69
+ * Maximum number of files for the uploader.
70
+ * @type number | undefined
71
+ * @default 1
72
+ * @example maxFileCount={4}
73
+ */
74
+ maxFileCount?: DropzoneProps['maxFiles'];
75
+
76
+ /**
77
+ * Whether the uploader should accept multiple files.
78
+ * @type boolean
79
+ * @default false
80
+ * @example multiple
81
+ */
82
+ multiple?: boolean;
83
+
84
+ /**
85
+ * Whether the uploader is disabled.
86
+ * @type boolean
87
+ * @default false
88
+ * @example disabled
89
+ */
90
+ disabled?: boolean;
91
+ }
92
+
93
+ export function FileUploader(props: FileUploaderProps) {
94
+ const {
95
+ value: valueProp,
96
+ onValueChange,
97
+ onUpload,
98
+ accept = {},
99
+ maxSize = 1024 * 1024 * 2,
100
+ maxFileCount = 1,
101
+ multiple = false,
102
+ disabled = false,
103
+ className,
104
+ ...dropzoneProps
105
+ } = props;
106
+
107
+ const [files, setFiles] = useControllableState({
108
+ prop: valueProp,
109
+ onChange: onValueChange,
110
+ });
111
+
112
+ const onDrop = useCallback(
113
+ (acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
114
+ if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) {
115
+ toast.error('Cannot upload more than 1 file at a time');
116
+ return;
117
+ }
118
+
119
+ if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) {
120
+ toast.error(`Cannot upload more than ${maxFileCount} files`);
121
+ return;
122
+ }
123
+
124
+ const newFiles: StatedFile[] = acceptedFiles.map((file) => ({
125
+ rawFile: file,
126
+ url: URL.createObjectURL(file),
127
+ status: 'pending',
128
+ }));
129
+
130
+ const updatedFiles = files ? [...files, ...newFiles] : newFiles;
131
+ setFiles(updatedFiles);
132
+
133
+ if (rejectedFiles.length > 0) {
134
+ rejectedFiles.forEach(({ file }) => {
135
+ toast.error(`File ${file.name} was rejected`);
136
+ });
137
+ }
138
+ },
139
+ [files, maxFileCount, multiple, onUpload, setFiles]
140
+ );
141
+
142
+ function onRemove(index: number) {
143
+ if (files?.[index]?.url) {
144
+ URL.revokeObjectURL(files[index].url);
145
+ }
146
+
147
+ const newFiles = files?.filter((_, i) => i !== index);
148
+ setFiles(newFiles);
149
+ }
150
+
151
+ const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount;
152
+
153
+ const [uploading, setUploading] = useState(false);
154
+
155
+ async function onSubmit() {
156
+ if (uploading || !files || !files.length) return;
157
+ setUploading(true);
158
+ await onUpload?.(files);
159
+ setUploading(false);
160
+ }
161
+
162
+ return (
163
+ <div className="relative flex flex-col overflow-hidden">
164
+ <Dropzone
165
+ onDrop={onDrop}
166
+ accept={accept}
167
+ maxSize={maxSize}
168
+ maxFiles={maxFileCount}
169
+ multiple={maxFileCount > 1 || multiple}
170
+ disabled={isDisabled}
171
+ >
172
+ {({ getRootProps, getInputProps, isDragActive }) => (
173
+ <div
174
+ {...getRootProps()}
175
+ className={cn(
176
+ 'group relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed border-muted-foreground/25 px-6 py-2 text-center transition hover:bg-muted/25',
177
+ 'ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden',
178
+ isDragActive && 'border-muted-foreground/50',
179
+ isDisabled && 'pointer-events-none opacity-60',
180
+ className
181
+ )}
182
+ {...dropzoneProps}
183
+ >
184
+ <input {...getInputProps()} />
185
+ {isDragActive ? (
186
+ <div className="flex flex-col items-center justify-center gap-4 sm:px-5">
187
+ <div className="rounded-full border border-dashed p-3">
188
+ <Upload
189
+ className="size-7 text-muted-foreground"
190
+ aria-hidden="true"
191
+ />
192
+ </div>
193
+ <p className="font-medium text-muted-foreground">
194
+ Drop the files here
195
+ </p>
196
+ </div>
197
+ ) : (
198
+ <div className="flex flex-col items-center justify-center gap-4 sm:px-5">
199
+ <div className="rounded-full border border-dashed p-3">
200
+ <Upload
201
+ className="size-7 text-muted-foreground"
202
+ aria-hidden="true"
203
+ />
204
+ </div>
205
+ <div className="flex flex-col gap-px">
206
+ <p className="font-medium text-muted-foreground">
207
+ Drag {`'n'`} drop files here, or click to select files
208
+ </p>
209
+ <p className="text-sm text-muted-foreground/70">
210
+ You can upload
211
+ {maxFileCount > 1
212
+ ? ` ${maxFileCount === Infinity ? 'multiple' : maxFileCount}
213
+ files (up to ${formatBytes(maxSize)} each)`
214
+ : ` a file with ${formatBytes(maxSize)}`}
215
+ </p>
216
+ </div>
217
+ </div>
218
+ )}
219
+ </div>
220
+ )}
221
+ </Dropzone>
222
+
223
+ {files?.length ? (
224
+ <ScrollArea className="h-fit w-full p-2 px-3">
225
+ <div className="flex max-h-48 flex-col gap-1 py-4">
226
+ {files?.map((file, index) => (
227
+ <FileCard
228
+ key={index}
229
+ file={file}
230
+ onRemove={() => onRemove(index)}
231
+ />
232
+ ))}
233
+ </div>
234
+ </ScrollArea>
235
+ ) : null}
236
+
237
+ <Separator className={files?.length ? 'mb-4' : 'my-4'} />
238
+
239
+ <div className="flex gap-2">
240
+ <Button
241
+ type="button"
242
+ className="w-fit"
243
+ onClick={() => {
244
+ setFiles([]);
245
+ }}
246
+ variant="ghost"
247
+ disabled={
248
+ uploading ||
249
+ (files?.length ?? 0) === 0 ||
250
+ ((files?.length ?? 0) > 0 &&
251
+ files?.every((file) => file.status === 'uploaded'))
252
+ }
253
+ >
254
+ Clear Files
255
+ </Button>
256
+ <Button
257
+ type="button"
258
+ className="w-full"
259
+ onClick={onSubmit}
260
+ disabled={
261
+ uploading ||
262
+ (files?.length ?? 0) === 0 ||
263
+ ((files?.length ?? 0) > 0 &&
264
+ files?.every((file) => file.status === 'uploaded'))
265
+ }
266
+ >
267
+ Upload Files
268
+ </Button>
269
+ </div>
270
+ </div>
271
+ );
272
+ }
273
+
274
+ interface FileCardProps {
275
+ file: StatedFile;
276
+ onRemove: () => void;
277
+ }
278
+
279
+ function FileCard({ file, onRemove }: FileCardProps) {
280
+ return (
281
+ <div className="relative flex items-center gap-2 rounded-md p-2 hover:bg-foreground/5">
282
+ <div className="flex flex-1 gap-2">
283
+ <div className="aspect-square size-10 flex-none">
284
+ <FilePreview file={file} />
285
+ </div>
286
+ <div className="flex w-full flex-col items-start gap-2 text-start">
287
+ <div className="flex flex-col gap-px">
288
+ <p className="line-clamp-1 text-sm font-semibold text-foreground/80">
289
+ {file.rawFile.name}
290
+ </p>
291
+ <div className="text-xs font-semibold text-muted-foreground">
292
+ {file.status === 'pending' && (
293
+ <span>{formatBytes(file.rawFile.size)}</span>
294
+ )}
295
+ {file.status === 'uploading' && (
296
+ <span className="opacity-70">Uploading...</span>
297
+ )}
298
+ {file.status === 'uploaded' && <span>Uploaded</span>}
299
+ {file.status === 'error' && (
300
+ <span className="text-destructive">Error</span>
301
+ )}
302
+ </div>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ <div className="flex items-center gap-2">
307
+ <Button
308
+ type="button"
309
+ variant="outline"
310
+ size="icon"
311
+ className="size-7"
312
+ onClick={onRemove}
313
+ >
314
+ <X className="size-4" aria-hidden="true" />
315
+ <span className="sr-only">Remove file</span>
316
+ </Button>
317
+ </div>
318
+ </div>
319
+ );
320
+ }
321
+
322
+ function FilePreview({ file }: { file: StatedFile }) {
323
+ const isImage = file.rawFile.type.startsWith('image/');
324
+ const isPdf = file.rawFile.type.startsWith('application/pdf');
325
+ const isOther = !isImage && !isPdf;
326
+
327
+ return (
328
+ <>
329
+ {isImage && (
330
+ <a href={file.url} target="_blank" rel="noopener noreferrer">
331
+ <img
332
+ src={file.url}
333
+ alt={file.rawFile.name}
334
+ width={48}
335
+ height={48}
336
+ loading="lazy"
337
+ className="rounded-md object-cover"
338
+ />
339
+ </a>
340
+ )}
341
+ {isPdf && (
342
+ <a href={file.url} target="_blank" rel="noopener noreferrer">
343
+ <FileText className="size-10" aria-hidden="true" />
344
+ </a>
345
+ )}
346
+ {isOther && <File className="size-10" aria-hidden="true" />}
347
+ </>
348
+ );
349
+ }
@@ -0,0 +1,29 @@
1
+ import { Input } from '../input';
2
+ import { Label } from '../label';
3
+ import React, { forwardRef } from 'react';
4
+
5
+ interface InputFieldProps {
6
+ id: string;
7
+ label?: string;
8
+ placeholder?: string;
9
+ }
10
+
11
+ export interface InputProps
12
+ extends React.InputHTMLAttributes<HTMLInputElement> {}
13
+
14
+ // Merge the two interfaces
15
+ type Props = InputFieldProps & InputProps;
16
+
17
+ const InputField = forwardRef<HTMLInputElement, Props>(
18
+ ({ id, label, ...props }, ref) => {
19
+ return (
20
+ <div className="grid w-full gap-2">
21
+ {label && <Label htmlFor={id}>{label}</Label>}
22
+ <Input id={id} ref={ref} {...props} />
23
+ </div>
24
+ );
25
+ }
26
+ );
27
+
28
+ InputField.displayName = 'InputField';
29
+ export { InputField };
@@ -0,0 +1,28 @@
1
+ import { cn } from '@tuturuuu/utils/format';
2
+
3
+ export function LoadingIndicator({ className }: { className?: string }) {
4
+ return (
5
+ <div className="inline-flex items-center rounded-md border border-transparent text-base">
6
+ <svg
7
+ className={cn('animate-spin text-foreground', className || 'h-4')}
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ fill="none"
10
+ viewBox="0 0 24 24"
11
+ >
12
+ <circle
13
+ className="opacity-25"
14
+ cx="12"
15
+ cy="12"
16
+ r="10"
17
+ stroke="currentColor"
18
+ strokeWidth="4"
19
+ ></circle>
20
+ <path
21
+ className="opacity-75"
22
+ fill="currentColor"
23
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
24
+ ></path>
25
+ </svg>
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ DialogTrigger,
10
+ } from '../dialog';
11
+ import { ReactElement, ReactNode, cloneElement, useState } from 'react';
12
+
13
+ interface FormProps<T> {
14
+ data?: T;
15
+ forceDefault?: boolean;
16
+ onFinish?: () => void;
17
+ }
18
+
19
+ interface Props<T> {
20
+ data?: T & { id?: string };
21
+ trigger?: ReactNode;
22
+ form?: ReactElement<FormProps<T>>;
23
+ open?: boolean;
24
+ title?: string;
25
+ editDescription?: string;
26
+ createDescription?: string;
27
+ requireExpansion?: boolean;
28
+ forceDefault?: boolean;
29
+ // eslint-disable-next-line no-unused-vars
30
+ setOpen?: (open: boolean) => void;
31
+ }
32
+
33
+ export default function ModifiableDialogTrigger<T>({
34
+ data,
35
+ trigger,
36
+ form,
37
+ open: externalOpen,
38
+ title,
39
+ editDescription,
40
+ createDescription,
41
+ requireExpansion,
42
+ forceDefault,
43
+ setOpen: setExternalOpen,
44
+ }: Props<T>) {
45
+ const [internalOpen, setInternalOpen] = useState(false);
46
+
47
+ const open = externalOpen ?? internalOpen;
48
+ const setOpen = setExternalOpen ?? setInternalOpen;
49
+
50
+ const formWithCallback = form
51
+ ? cloneElement(
52
+ form as ReactElement,
53
+ {
54
+ data,
55
+ forceDefault,
56
+ onFinish: () => setOpen(false),
57
+ } as FormProps<T>
58
+ )
59
+ : null;
60
+
61
+ return (
62
+ <Dialog open={open} onOpenChange={setOpen}>
63
+ {trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
64
+ <DialogContent
65
+ onOpenAutoFocus={(e) => e.preventDefault()}
66
+ // onInteractOutside={(e) => e.preventDefault()}
67
+ className={
68
+ requireExpansion
69
+ ? 'md:max-w-2xl lg:max-w-4xl xl:max-w-6xl'
70
+ : undefined
71
+ }
72
+ >
73
+ <DialogHeader>
74
+ <DialogTitle>{title}</DialogTitle>
75
+ <DialogDescription>
76
+ {data?.id ? editDescription : createDescription}
77
+ </DialogDescription>
78
+ </DialogHeader>
79
+ {formWithCallback}
80
+ </DialogContent>
81
+ </Dialog>
82
+ );
83
+ }
@@ -0,0 +1,157 @@
1
+ 'use client';
2
+
3
+ import { Button } from '../button';
4
+ import { Popover, PopoverContent, PopoverTrigger } from '../popover';
5
+ import {
6
+ add,
7
+ eachMonthOfInterval,
8
+ endOfYear,
9
+ format,
10
+ isFuture,
11
+ parse,
12
+ } from 'date-fns';
13
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
14
+ import { useState } from 'react';
15
+
16
+ interface MonthPickerProps {
17
+ lang: string;
18
+ resetPage?: boolean;
19
+ className?: string;
20
+ defaultMonth?: string;
21
+ onUpdate?: (
22
+ // eslint-disable-next-line no-unused-vars
23
+ args: {
24
+ month: string;
25
+ page?: string;
26
+ },
27
+ // eslint-disable-next-line no-unused-vars
28
+ refresh: boolean
29
+ ) => void;
30
+ }
31
+
32
+ export default function MonthPicker({
33
+ lang,
34
+ defaultMonth,
35
+ resetPage = true,
36
+ className,
37
+ onUpdate,
38
+ }: MonthPickerProps) {
39
+ const queryMonth = defaultMonth;
40
+
41
+ const currentYYYYMM = Array.isArray(queryMonth)
42
+ ? queryMonth?.[0] || format(new Date(), 'yyyy-MM')
43
+ : queryMonth || format(new Date(), 'yyyy-MM');
44
+
45
+ const currentMonth =
46
+ typeof currentYYYYMM === 'string'
47
+ ? parse(currentYYYYMM, 'yyyy-MM', new Date())
48
+ : new Date();
49
+
50
+ const [open, setOpen] = useState(false);
51
+ const [previewDate, setPreviewDate] = useState(currentMonth);
52
+
53
+ const updateQuery = (month: string) => {
54
+ if (onUpdate) onUpdate({ month, page: resetPage ? '1' : undefined }, false);
55
+ setOpen(false);
56
+ };
57
+
58
+ const firstDayCurrentYear = new Date(previewDate.getFullYear(), 0, 1);
59
+
60
+ const months = eachMonthOfInterval({
61
+ start: firstDayCurrentYear,
62
+ end: endOfYear(firstDayCurrentYear),
63
+ });
64
+
65
+ function previousYear() {
66
+ let firstDayLastYear = add(firstDayCurrentYear, { years: -1 });
67
+ setPreviewDate(firstDayLastYear);
68
+ }
69
+
70
+ function nextYear() {
71
+ let firstDayNextYear = add(firstDayCurrentYear, { years: 1 });
72
+ setPreviewDate(firstDayNextYear);
73
+ }
74
+
75
+ return (
76
+ <Popover open={open} onOpenChange={setOpen}>
77
+ <PopoverTrigger asChild>
78
+ <Button
79
+ size="xs"
80
+ variant="outline"
81
+ onClick={() => setOpen((prev) => !prev)}
82
+ className={className}
83
+ >
84
+ {currentMonth.toLocaleString(lang, {
85
+ month: '2-digit',
86
+ year: 'numeric',
87
+ })}
88
+ </Button>
89
+ </PopoverTrigger>
90
+ <PopoverContent className="w-80">
91
+ <div className="flex items-center justify-between pb-2">
92
+ <Button
93
+ size="xs"
94
+ variant="outline"
95
+ name="previous-year"
96
+ aria-label="Go to previous year"
97
+ onClick={previousYear}
98
+ >
99
+ <ChevronLeft className="h-4 w-4" />
100
+ </Button>
101
+
102
+ <div
103
+ className="text-sm font-medium"
104
+ aria-live="polite"
105
+ role="presentation"
106
+ id="month-picker"
107
+ >
108
+ {format(firstDayCurrentYear, 'yyyy')}
109
+ </div>
110
+
111
+ <Button
112
+ size="xs"
113
+ variant="outline"
114
+ name="next-year"
115
+ aria-label="Go to next year"
116
+ onClick={nextYear}
117
+ disabled={
118
+ firstDayCurrentYear.getFullYear() >= new Date().getFullYear()
119
+ }
120
+ >
121
+ <ChevronRight className="h-4 w-4" />
122
+ </Button>
123
+ </div>
124
+
125
+ <div
126
+ className="grid w-full grid-cols-3 gap-2"
127
+ role="grid"
128
+ aria-labelledby="month-picker"
129
+ >
130
+ {months.map((month) => (
131
+ <div
132
+ key={month.toString()}
133
+ className="relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-slate-100 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md dark:[&:has([aria-selected])]:bg-slate-800"
134
+ role="presentation"
135
+ >
136
+ <Button
137
+ variant={
138
+ currentMonth.getMonth() === month.getMonth() &&
139
+ currentMonth.getFullYear() === month.getFullYear()
140
+ ? 'default'
141
+ : 'ghost'
142
+ }
143
+ className="w-full"
144
+ disabled={isFuture(month)}
145
+ onClick={() => updateQuery(format(month, 'yyyy-MM'))}
146
+ >
147
+ <time dateTime={format(month, 'yyyy-MM-dd')}>
148
+ {month.toLocaleString(lang, { month: 'short' })}
149
+ </time>
150
+ </Button>
151
+ </div>
152
+ ))}
153
+ </div>
154
+ </PopoverContent>
155
+ </Popover>
156
+ );
157
+ }