UNPKG

@madulinux/react-datatable

Version:

Reusable DataTable component for React with sorting, paging, filter, and export.

1 lines 840 kB
{"version":3,"sources":["../src/DataTablePagination.tsx","../src/ui/pagination.tsx","../src/lib/utils.ts","../src/ui/button.tsx","../node_modules/@radix-ui/react-slot/src/slot.tsx","../node_modules/@radix-ui/react-compose-refs/src/compose-refs.tsx","../src/Select2.tsx","../node_modules/@radix-ui/react-popover/src/popover.tsx","../node_modules/@radix-ui/primitive/src/primitive.tsx","../node_modules/@radix-ui/react-context/src/create-context.tsx","../node_modules/@radix-ui/react-dismissable-layer/src/dismissable-layer.tsx","../node_modules/@radix-ui/react-primitive/src/primitive.tsx","../node_modules/@radix-ui/react-use-callback-ref/src/use-callback-ref.tsx","../node_modules/@radix-ui/react-use-escape-keydown/src/use-escape-keydown.tsx","../node_modules/@radix-ui/react-focus-guards/src/focus-guards.tsx","../node_modules/@radix-ui/react-focus-scope/src/focus-scope.tsx","../node_modules/@radix-ui/react-id/src/id.tsx","../node_modules/@radix-ui/react-use-layout-effect/src/use-layout-effect.tsx","../node_modules/@radix-ui/react-popper/src/popper.tsx","../node_modules/@floating-ui/utils/dist/floating-ui.utils.mjs","../node_modules/@floating-ui/core/dist/floating-ui.core.mjs","../node_modules/@floating-ui/utils/dist/floating-ui.utils.dom.mjs","../node_modules/@floating-ui/dom/dist/floating-ui.dom.mjs","../node_modules/@floating-ui/react-dom/dist/floating-ui.react-dom.mjs","../node_modules/@radix-ui/react-arrow/src/arrow.tsx","../node_modules/@radix-ui/react-use-size/src/use-size.tsx","../node_modules/@radix-ui/react-portal/src/portal.tsx","../node_modules/@radix-ui/react-presence/src/presence.tsx","../node_modules/@radix-ui/react-presence/src/use-state-machine.tsx","../node_modules/@radix-ui/react-use-controllable-state/src/use-controllable-state.tsx","../node_modules/@radix-ui/react-use-controllable-state/src/use-controllable-state-reducer.tsx","../node_modules/aria-hidden/dist/es2015/index.js","../node_modules/tslib/tslib.es6.mjs","../node_modules/react-remove-scroll/dist/es2015/Combination.js","../node_modules/react-remove-scroll/dist/es2015/UI.js","../node_modules/react-remove-scroll-bar/dist/es2015/constants.js","../node_modules/use-callback-ref/dist/es2015/assignRef.js","../node_modules/use-callback-ref/dist/es2015/useRef.js","../node_modules/use-callback-ref/dist/es2015/useMergeRef.js","../node_modules/use-sidecar/dist/es2015/medium.js","../node_modules/use-sidecar/dist/es2015/exports.js","../node_modules/react-remove-scroll/dist/es2015/medium.js","../node_modules/react-remove-scroll/dist/es2015/SideEffect.js","../node_modules/react-remove-scroll-bar/dist/es2015/component.js","../node_modules/react-style-singleton/dist/es2015/hook.js","../node_modules/get-nonce/dist/es2015/index.js","../node_modules/react-style-singleton/dist/es2015/singleton.js","../node_modules/react-style-singleton/dist/es2015/component.js","../node_modules/react-remove-scroll-bar/dist/es2015/utils.js","../node_modules/react-remove-scroll/dist/es2015/aggresiveCapture.js","../node_modules/react-remove-scroll/dist/es2015/handleScroll.js","../node_modules/react-remove-scroll/dist/es2015/sidecar.js","../src/ui/popover.tsx","../src/ui/command.tsx","../src/ui/dialog.tsx","../src/ui/input.tsx","../node_modules/@radix-ui/react-select/src/select.tsx","../node_modules/@radix-ui/number/src/number.ts","../node_modules/@radix-ui/react-collection/src/collection-legacy.tsx","../node_modules/@radix-ui/react-collection/src/collection.tsx","../node_modules/@radix-ui/react-collection/src/ordered-dictionary.ts","../node_modules/@radix-ui/react-direction/src/direction.tsx","../node_modules/@radix-ui/react-use-previous/src/use-previous.tsx","../node_modules/@radix-ui/react-visually-hidden/src/visually-hidden.tsx","../src/ui/select.tsx","../src/ui/date-picker.tsx","../src/ui/calendar.tsx","../src/AdvancedFilterValueInput.tsx","../node_modules/@radix-ui/react-checkbox/src/checkbox.tsx","../src/ui/checkbox.tsx","../src/dataTableUtils.ts","../src/DataTableFilterInput.tsx","../src/DataTable.tsx","../node_modules/@radix-ui/react-dropdown-menu/src/dropdown-menu.tsx","../node_modules/@radix-ui/react-menu/src/menu.tsx","../node_modules/@radix-ui/react-roving-focus/src/roving-focus-group.tsx","../src/ui/dropdown-menu.tsx","../src/ui/table.tsx","../src/index.tsx"],"sourcesContent":["import React from 'react';\nimport { ChevronsLeft, ChevronsRight } from 'lucide-react';\nimport { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from './ui/pagination';\n\ninterface DataTablePaginationProps {\n page: number;\n totalPages: number;\n perPage: number;\n total: number;\n dataLength: number;\n screenSize: 'mobile' | 'tablet' | 'desktop';\n showRecordInfo?: boolean;\n paginationAlignment?: 'left' | 'center' | 'right' | 'between';\n compactMode?: boolean;\n isMobileView?: boolean;\n onPageChange: (page: number) => void;\n}\n\nfunction classNames(...classes: (string | boolean | undefined)[]) {\n return classes.filter(Boolean).join(' ');\n}\n\nconst PAGINATION_CONFIG = {\n maxPagesMobile: 3,\n maxPagesDesktop: 5,\n} as const;\n\n/**\n * DataTablePagination - Pagination component for DataTable\n * Handles page navigation and displays record information\n */\nexport function DataTablePagination({\n page,\n totalPages,\n perPage,\n total,\n dataLength,\n screenSize,\n showRecordInfo = true,\n paginationAlignment = 'between',\n compactMode = true,\n isMobileView = false,\n onPageChange,\n}: DataTablePaginationProps) {\n if (totalPages <= 1) return null;\n\n const maxPage = screenSize === 'mobile' ? PAGINATION_CONFIG.maxPagesMobile : PAGINATION_CONFIG.maxPagesDesktop;\n\n return (\n <div\n className={classNames(\n 'flex gap-2',\n paginationAlignment === 'left'\n ? 'justify-start'\n : paginationAlignment === 'center'\n ? 'justify-center'\n : paginationAlignment === 'right'\n ? 'justify-end'\n : paginationAlignment === 'between'\n ? 'flex-col justify-between sm:flex-row sm:items-center'\n : 'flex-col justify-between sm:flex-row sm:items-center',\n )}\n >\n {/* Record Info */}\n {showRecordInfo && (\n <div\n className={classNames(\n 'text-sm text-gray-600',\n paginationAlignment === 'between' ? 'order-2 sm:order-1' : '',\n compactMode && isMobileView && 'text-center text-xs sm:text-left',\n )}\n role=\"status\"\n aria-live=\"polite\"\n >\n Menampilkan {dataLength === 0 ? 0 : (page - 1) * perPage + 1}\n {' - '}\n {dataLength === 0 ? 0 : (page - 1) * perPage + dataLength}\n {' dari '}\n {total} data\n </div>\n )}\n\n {/* Pagination Controls */}\n <div\n className={classNames(\n 'flex justify-center',\n paginationAlignment === 'between'\n ? 'order-1 w-full sm:order-2 sm:w-auto'\n : paginationAlignment === 'left'\n ? 'w-full justify-start'\n : paginationAlignment === 'right'\n ? 'w-full justify-end'\n : 'w-full',\n )}\n >\n <Pagination>\n <PaginationContent>\n {/* First page */}\n {page > 1 && (\n <PaginationItem>\n <PaginationLink\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n onPageChange(1);\n }}\n isActive={page === 1}\n aria-label=\"Go to first page\"\n >\n <ChevronsLeft className=\"size-4\" />\n </PaginationLink>\n </PaginationItem>\n )}\n\n {/* Previous page */}\n {page > 1 && (\n <PaginationItem>\n <PaginationPrevious\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n if (page > 1) onPageChange(page - 1);\n }}\n aria-disabled={page === 1}\n style={page === 1 ? { pointerEvents: 'none', opacity: 0.5 } : {}}\n />\n </PaginationItem>\n )}\n\n {/* Page Numbers */}\n {Array.from({ length: Math.min(totalPages, maxPage) }, (_, i) => {\n let pageNum;\n if (totalPages <= maxPage) {\n pageNum = i + 1;\n } else if (page <= Math.floor(maxPage / 2)) {\n pageNum = i + 1;\n } else if (page >= totalPages - Math.floor(maxPage / 2)) {\n pageNum = totalPages - maxPage + i + 1;\n } else {\n pageNum = page - Math.floor(maxPage / 2) + i;\n }\n\n return (\n <PaginationItem key={pageNum}>\n <PaginationLink\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n onPageChange(pageNum);\n }}\n isActive={pageNum === page}\n aria-label={`Go to page ${pageNum}`}\n aria-current={pageNum === page ? 'page' : undefined}\n >\n {pageNum}\n </PaginationLink>\n </PaginationItem>\n );\n })}\n\n {/* Next page */}\n {page < totalPages && (\n <PaginationItem>\n <PaginationNext\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n if (page < totalPages) onPageChange(page + 1);\n }}\n aria-disabled={page === totalPages}\n style={page === totalPages ? { pointerEvents: 'none', opacity: 0.5 } : {}}\n />\n </PaginationItem>\n )}\n\n {/* Last page */}\n {page < totalPages && (\n <PaginationItem>\n <PaginationLink\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n onPageChange(totalPages);\n }}\n isActive={page === totalPages}\n aria-label=\"Go to last page\"\n >\n <ChevronsRight className=\"size-4\" />\n </PaginationLink>\n </PaginationItem>\n )}\n </PaginationContent>\n </Pagination>\n </div>\n </div>\n );\n}\n\nexport default DataTablePagination;\n","import * as React from \"react\"\nimport {\n ChevronLeftIcon,\n ChevronRightIcon,\n MoreHorizontalIcon,\n} from \"lucide-react\"\n\nimport { cn } from \"../lib/utils\"\nimport { Button, buttonVariants } from \"./button\"\n\nfunction Pagination({ className, ...props }: React.ComponentProps<\"nav\">) {\n return (\n <nav\n role=\"navigation\"\n aria-label=\"pagination\"\n data-slot=\"pagination\"\n className={cn(\"mx-auto flex w-full justify-center\", className)}\n {...props}\n />\n )\n}\n\nfunction PaginationContent({\n className,\n ...props\n}: React.ComponentProps<\"ul\">) {\n return (\n <ul\n data-slot=\"pagination-content\"\n className={cn(\"flex flex-row items-center gap-1\", className)}\n {...props}\n />\n )\n}\n\nfunction PaginationItem({ ...props }: React.ComponentProps<\"li\">) {\n return <li data-slot=\"pagination-item\" {...props} />\n}\n\ntype PaginationLinkProps = {\n isActive?: boolean\n} & Pick<React.ComponentProps<typeof Button>, \"size\"> &\n React.ComponentProps<\"a\">\n\nfunction PaginationLink({\n className,\n isActive,\n size = \"sm\",\n ...props\n}: PaginationLinkProps) {\n return (\n <a\n aria-current={isActive ? \"page\" : undefined}\n data-slot=\"pagination-link\"\n data-active={isActive}\n className={cn(\n buttonVariants({\n variant: isActive ? \"default\" : \"outline\",\n size,\n }),\n className\n )}\n {...props}\n />\n )\n}\n\nfunction PaginationPrevious({\n className,\n ...props\n}: React.ComponentProps<typeof PaginationLink>) {\n return (\n <PaginationLink\n aria-label=\"Go to previous page\"\n size=\"default\"\n className={cn(\"gap-1 px-2.5 sm:pl-2.5\", className)}\n {...props}\n >\n <ChevronLeftIcon />\n </PaginationLink>\n )\n}\n\nfunction PaginationNext({\n className,\n ...props\n}: React.ComponentProps<typeof PaginationLink>) {\n return (\n <PaginationLink\n aria-label=\"Go to next page\"\n size=\"default\"\n className={cn(\"gap-1 px-2.5 sm:pr-2.5\", className)}\n {...props}\n >\n <ChevronRightIcon />\n </PaginationLink>\n )\n}\n\nfunction PaginationEllipsis({\n className,\n ...props\n}: React.ComponentProps<\"span\">) {\n return (\n <span\n aria-hidden\n data-slot=\"pagination-ellipsis\"\n className={cn(\"flex size-9 items-center justify-center\", className)}\n {...props}\n >\n <MoreHorizontalIcon className=\"size-4\" />\n <span className=\"sr-only\">More pages</span>\n </span>\n )\n}\n\n// Helper untuk range dinamis pagination ala DataTables\nexport function getPaginationRange(current: number, total: number, siblingCount = 1) {\n const totalPageNumbers = siblingCount * 2 + 5; // 5: first, last, current, 2 ellipsis\n if (total <= totalPageNumbers) {\n return Array.from({ length: total }, (_, i) => i + 1);\n }\n const leftSibling = Math.max(current - siblingCount, 1);\n const rightSibling = Math.min(current + siblingCount, total);\n\n const showLeftEllipsis = leftSibling > 2;\n const showRightEllipsis = rightSibling < total - 1;\n\n const range: (number | 'ellipsis')[] = [];\n\n range.push(1);\n if (showLeftEllipsis) range.push('ellipsis');\n\n for (let i = leftSibling; i <= rightSibling; i++) {\n if (i !== 1 && i !== total) range.push(i);\n }\n\n if (showRightEllipsis) range.push('ellipsis');\n if (total !== 1) range.push(total);\n\n return range;\n}\n\nexport {\n Pagination,\n PaginationContent,\n PaginationLink,\n PaginationItem,\n PaginationPrevious,\n PaginationNext,\n PaginationEllipsis,\n}\n","import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n","import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"../lib/utils\"\n\nconst buttonVariants = cva(\n \"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\",\n {\n variants: {\n variant: {\n default:\n \"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90\",\n primary:\n \"bg-blue-600 text-white shadow-xs hover:bg-blue-700 focus-visible:ring-blue-200\",\n success:\n \"bg-green-600 text-white shadow-xs hover:bg-green-700 focus-visible:ring-green-200\",\n danger:\n \"bg-red-600 text-white shadow-xs hover:bg-red-700 focus-visible:ring-red-200\",\n warning:\n \"bg-amber-500 text-white shadow-xs hover:bg-amber-600 focus-visible:ring-amber-200\",\n info:\n \"bg-sky-500 text-white shadow-xs hover:bg-sky-600 focus-visible:ring-sky-200\",\n light:\n \"bg-gray-100 text-gray-800 shadow-xs hover:bg-gray-200 focus-visible:ring-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600\",\n dark:\n \"bg-gray-800 text-white shadow-xs hover:bg-gray-900 focus-visible:ring-gray-500\",\n destructive:\n \"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\",\n outline:\n \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50\",\n 'outline-primary':\n \"border border-blue-600 text-blue-600 bg-transparent shadow-xs hover:bg-blue-50 hover:text-blue-700 dark:hover:bg-blue-900/20\",\n 'outline-success':\n \"border border-green-600 text-green-600 bg-transparent shadow-xs hover:bg-green-50 hover:text-green-700 dark:hover:bg-green-900/20\",\n 'outline-danger':\n \"border border-red-600 text-red-600 bg-transparent shadow-xs hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-900/20\",\n 'outline-warning':\n \"border border-amber-500 text-amber-500 bg-transparent shadow-xs hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20\",\n 'outline-info':\n \"border border-sky-500 text-sky-500 bg-transparent shadow-xs hover:bg-sky-50 hover:text-sky-600 dark:hover:bg-sky-900/20\",\n secondary:\n \"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80\",\n ghost:\n \"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n link: \"text-primary underline-offset-4 hover:underline\",\n },\n size: {\n default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n sm: \"h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5\",\n lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n xl: \"h-12 rounded-md px-8 text-base has-[>svg]:px-6\",\n icon: \"size-9\",\n },\n isBlock: {\n true: \"w-full justify-center\",\n },\n isDisabled: {\n true: \"opacity-50 cursor-not-allowed pointer-events-none\",\n },\n isLoading: {\n true: \"relative text-transparent transition-none hover:text-transparent\",\n }\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n isBlock: false,\n isDisabled: false,\n isLoading: false,\n },\n }\n)\n\nexport interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n VariantProps<typeof buttonVariants> {\n asChild?: boolean;\n isBlock?: boolean;\n isDisabled?: boolean;\n isLoading?: boolean;\n leftIcon?: React.ReactNode;\n rightIcon?: React.ReactNode;\n loadingText?: string;\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({\n className,\n variant,\n size,\n isBlock,\n isDisabled,\n isLoading,\n leftIcon,\n rightIcon,\n loadingText,\n asChild = false,\n children,\n ...props\n}, ref) => {\n const Comp = asChild ? Slot : \"button\";\n const disabled = isDisabled || isLoading || props.disabled;\n \n // Jika asChild true, kita tidak bisa menggunakan Fragment atau wrapper tambahan\n // karena Slot hanya menerima satu child\n if (asChild) {\n return (\n <Comp\n className={cn(\n buttonVariants({ variant, size, isBlock, isDisabled, isLoading, className })\n )}\n ref={ref}\n disabled={disabled}\n data-slot=\"button\"\n {...props}\n >\n {children}\n </Comp>\n );\n }\n \n // Jika bukan asChild, kita bisa menggunakan button biasa dengan semua fitur\n return (\n <Comp\n className={cn(\n buttonVariants({ variant, size, isBlock, isDisabled, isLoading, className })\n )}\n ref={ref}\n disabled={disabled}\n data-slot=\"button\"\n {...props}\n >\n {isLoading && (\n <div className=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2\">\n <div className=\"size-4 animate-spin rounded-full border-2 border-current border-t-transparent\"></div>\n </div>\n )}\n {leftIcon && <span className=\"inline-flex\">{leftIcon}</span>}\n {isLoading && loadingText ? loadingText : children}\n {rightIcon && <span className=\"inline-flex\">{rightIcon}</span>}\n </Comp>\n );\n});\n\nButton.displayName = \"Button\";\n\nexport { Button, buttonVariants };\n","import * as React from 'react';\nimport { composeRefs } from '@radix-ui/react-compose-refs';\n\n/* -------------------------------------------------------------------------------------------------\n * Slot\n * -----------------------------------------------------------------------------------------------*/\n\ninterface SlotProps extends React.HTMLAttributes<HTMLElement> {\n children?: React.ReactNode;\n}\n\n/* @__NO_SIDE_EFFECTS__ */ export function createSlot(ownerName: string) {\n const SlotClone = createSlotClone(ownerName);\n const Slot = React.forwardRef<HTMLElement, SlotProps>((props, forwardedRef) => {\n const { children, ...slotProps } = props;\n const childrenArray = React.Children.toArray(children);\n const slottable = childrenArray.find(isSlottable);\n\n if (slottable) {\n // the new element to render is the one passed as a child of `Slottable`\n const newElement = slottable.props.children;\n\n const newChildren = childrenArray.map((child) => {\n if (child === slottable) {\n // because the new element will be the one rendered, we are only interested\n // in grabbing its children (`newElement.props.children`)\n if (React.Children.count(newElement) > 1) return React.Children.only(null);\n return React.isValidElement(newElement)\n ? (newElement.props as { children: React.ReactNode }).children\n : null;\n } else {\n return child;\n }\n });\n\n return (\n <SlotClone {...slotProps} ref={forwardedRef}>\n {React.isValidElement(newElement)\n ? React.cloneElement(newElement, undefined, newChildren)\n : null}\n </SlotClone>\n );\n }\n\n return (\n <SlotClone {...slotProps} ref={forwardedRef}>\n {children}\n </SlotClone>\n );\n });\n\n Slot.displayName = `${ownerName}.Slot`;\n return Slot;\n}\n\nconst Slot = createSlot('Slot');\n\n/* -------------------------------------------------------------------------------------------------\n * SlotClone\n * -----------------------------------------------------------------------------------------------*/\n\ninterface SlotCloneProps {\n children: React.ReactNode;\n}\n\n/* @__NO_SIDE_EFFECTS__ */ function createSlotClone(ownerName: string) {\n const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {\n const { children, ...slotProps } = props;\n\n if (React.isValidElement(children)) {\n const childrenRef = getElementRef(children);\n const props = mergeProps(slotProps, children.props as AnyProps);\n // do not pass ref to React.Fragment for React 19 compatibility\n if (children.type !== React.Fragment) {\n props.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;\n }\n return React.cloneElement(children, props);\n }\n\n return React.Children.count(children) > 1 ? React.Children.only(null) : null;\n });\n\n SlotClone.displayName = `${ownerName}.SlotClone`;\n return SlotClone;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Slottable\n * -----------------------------------------------------------------------------------------------*/\n\nconst SLOTTABLE_IDENTIFIER = Symbol('radix.slottable');\n\ninterface SlottableProps {\n children: React.ReactNode;\n}\n\ninterface SlottableComponent extends React.FC<SlottableProps> {\n __radixId: symbol;\n}\n\n/* @__NO_SIDE_EFFECTS__ */ export function createSlottable(ownerName: string) {\n const Slottable: SlottableComponent = ({ children }) => {\n return <>{children}</>;\n };\n Slottable.displayName = `${ownerName}.Slottable`;\n Slottable.__radixId = SLOTTABLE_IDENTIFIER;\n return Slottable;\n}\n\nconst Slottable = createSlottable('Slottable');\n\n/* ---------------------------------------------------------------------------------------------- */\n\ntype AnyProps = Record<string, any>;\n\nfunction isSlottable(\n child: React.ReactNode\n): child is React.ReactElement<SlottableProps, typeof Slottable> {\n return (\n React.isValidElement(child) &&\n typeof child.type === 'function' &&\n '__radixId' in child.type &&\n child.type.__radixId === SLOTTABLE_IDENTIFIER\n );\n}\n\nfunction mergeProps(slotProps: AnyProps, childProps: AnyProps) {\n // all child props should override\n const overrideProps = { ...childProps };\n\n for (const propName in childProps) {\n const slotPropValue = slotProps[propName];\n const childPropValue = childProps[propName];\n\n const isHandler = /^on[A-Z]/.test(propName);\n if (isHandler) {\n // if the handler exists on both, we compose them\n if (slotPropValue && childPropValue) {\n overrideProps[propName] = (...args: unknown[]) => {\n const result = childPropValue(...args);\n slotPropValue(...args);\n return result;\n };\n }\n // but if it exists only on the slot, we use only this one\n else if (slotPropValue) {\n overrideProps[propName] = slotPropValue;\n }\n }\n // if it's `style`, we merge them\n else if (propName === 'style') {\n overrideProps[propName] = { ...slotPropValue, ...childPropValue };\n } else if (propName === 'className') {\n overrideProps[propName] = [slotPropValue, childPropValue].filter(Boolean).join(' ');\n }\n }\n\n return { ...slotProps, ...overrideProps };\n}\n\n// Before React 19 accessing `element.props.ref` will throw a warning and suggest using `element.ref`\n// After React 19 accessing `element.ref` does the opposite.\n// https://github.com/facebook/react/pull/28348\n//\n// Access the ref using the method that doesn't yield a warning.\nfunction getElementRef(element: React.ReactElement) {\n // React <=18 in DEV\n let getter = Object.getOwnPropertyDescriptor(element.props, 'ref')?.get;\n let mayWarn = getter && 'isReactWarning' in getter && getter.isReactWarning;\n if (mayWarn) {\n return (element as any).ref;\n }\n\n // React 19 in DEV\n getter = Object.getOwnPropertyDescriptor(element, 'ref')?.get;\n mayWarn = getter && 'isReactWarning' in getter && getter.isReactWarning;\n if (mayWarn) {\n return (element.props as { ref?: React.Ref<unknown> }).ref;\n }\n\n // Not DEV\n return (element.props as { ref?: React.Ref<unknown> }).ref || (element as any).ref;\n}\n\nexport {\n Slot,\n Slottable,\n //\n Slot as Root,\n};\nexport type { SlotProps };\n","import * as React from 'react';\n\ntype PossibleRef<T> = React.Ref<T> | undefined;\n\n/**\n * Set a given ref to a given value\n * This utility takes care of different types of refs: callback refs and RefObject(s)\n */\nfunction setRef<T>(ref: PossibleRef<T>, value: T) {\n if (typeof ref === 'function') {\n return ref(value);\n } else if (ref !== null && ref !== undefined) {\n ref.current = value;\n }\n}\n\n/**\n * A utility to compose multiple refs together\n * Accepts callback refs and RefObject(s)\n */\nfunction composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {\n return (node) => {\n let hasCleanup = false;\n const cleanups = refs.map((ref) => {\n const cleanup = setRef(ref, node);\n if (!hasCleanup && typeof cleanup == 'function') {\n hasCleanup = true;\n }\n return cleanup;\n });\n\n // React <19 will log an error to the console if a callback ref returns a\n // value. We don't use ref cleanups internally so this will only happen if a\n // user's ref callback returns a value, which we only expect if they are\n // using the cleanup functionality added in React 19.\n if (hasCleanup) {\n return () => {\n for (let i = 0; i < cleanups.length; i++) {\n const cleanup = cleanups[i];\n if (typeof cleanup == 'function') {\n cleanup();\n } else {\n setRef(refs[i], null);\n }\n }\n };\n }\n };\n}\n\n/**\n * A custom hook that composes multiple refs\n * Accepts callback refs and RefObject(s)\n */\nfunction useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {\n // eslint-disable-next-line react-hooks/exhaustive-deps\n return React.useCallback(composeRefs(...refs), refs);\n}\n\nexport { composeRefs, useComposedRefs };\n","import React, { useState, useEffect, useCallback, useRef } from \"react\";\nimport { Button } from \"./ui/button\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"./ui/popover\";\nimport {\n Command,\n CommandInput,\n CommandList,\n CommandEmpty,\n CommandGroup,\n CommandItem,\n} from \"./ui/command\";\nimport { Loader2, Check, ChevronsUpDown, X as XIcon } from \"lucide-react\";\nimport { cn } from \"./lib/utils\";\n\n/**\n * Props for the Select2 component\n * @template T - The type of items in the select, must have id and optional label\n */\nexport type Select2Props<T> = {\n /** Current selected value(s) */\n value: T | null | T[];\n /** Callback when selection changes */\n onChange: (val: T | null | T[]) => void;\n /** Function to fetch options with search and pagination */\n fetchOptions: (params: {\n search: string;\n page: number;\n }) => Promise<{ data: T[]; hasMore: boolean }>;\n /** Custom render function for each option */\n renderOption?: (item: T) => React.ReactNode;\n /** Custom render function for selected value(s) */\n renderSelected?: (item: T | null | T[]) => React.ReactNode;\n /** Placeholder text when no selection */\n placeholder?: string;\n /** Enable multi-select mode */\n isMulti?: boolean;\n /** Message when no options available */\n noOptionsMessage?: string | ((search: string) => React.ReactNode);\n /** Message during loading */\n loadingMessage?: string | React.ReactNode;\n /** Message when error occurs */\n errorMessage?: string | ((error: Error) => React.ReactNode);\n /** Additional CSS classes */\n className?: string;\n /** Disable the select */\n disabled?: boolean;\n /** Debounce delay in milliseconds for search input (default: 300) */\n debounceMs?: number;\n /** Minimum characters required before fetching options (default: 0) */\n minInput?: number;\n /** Maximum number of items that can be selected in multi-select mode */\n maxSelections?: number;\n /** Show Select All / Clear All buttons in multi-select mode */\n showSelectAll?: boolean;\n /** Callback when max selections reached */\n onMaxSelectionsReached?: () => void;\n};\n\n/**\n * Select2 - Advanced select component with async data fetching, search, and multi-select support\n * \n * @template T - Type of items, must have `id` and optional `label` properties\n * \n * @example\n * // Single select\n * <Select2\n * value={selectedUser}\n * onChange={setSelectedUser}\n * fetchOptions={async ({ search, page }) => ({\n * data: await fetchUsers(search, page),\n * hasMore: true\n * })}\n * />\n * \n * @example\n * // Multi-select with max selections\n * <Select2\n * isMulti\n * maxSelections={5}\n * value={selectedItems}\n * onChange={setSelectedItems}\n * fetchOptions={fetchItems}\n * onMaxSelectionsReached={() => toast.error('Max 5 items')}\n * />\n * \n * @example\n * // With minimum input length (for large datasets)\n * <Select2\n * value={selectedCity}\n * onChange={setSelectedCity}\n * fetchOptions={fetchCities}\n * minInput={2}\n * placeholder=\"Ketik minimal 2 huruf...\"\n * />\n * \n * @param props - Select2Props<T>\n * @returns React component\n */\nexport function Select2<T extends { id: string | number; label?: string }>({\n value,\n onChange,\n fetchOptions,\n renderOption,\n renderSelected,\n placeholder = \"Pilih...\",\n isMulti = false,\n noOptionsMessage = \"Tidak ada data\",\n loadingMessage = \"Memuat...\",\n errorMessage = \"Terjadi kesalahan saat memuat data\",\n className = \"\",\n disabled = false,\n debounceMs = 300,\n minInput = 0,\n maxSelections,\n showSelectAll = true,\n onMaxSelectionsReached,\n}: Select2Props<T>) {\n const [open, setOpen] = useState(false);\n const [search, setSearch] = useState(\"\");\n const [debouncedSearch, setDebouncedSearch] = useState(\"\");\n const [page, setPage] = useState(1);\n const [options, setOptions] = useState<T[]>([]);\n const [hasMore, setHasMore] = useState(false);\n const [loading, setLoading] = useState(false);\n const [loadingMore, setLoadingMore] = useState(false);\n const [error, setError] = useState<Error | null>(null);\n const [selected, setSelected] = useState<T | T[] | null>(value);\n const [announcement, setAnnouncement] = useState<string>(\"\");\n const abortControllerRef = useRef<AbortController | null>(null);\n const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const errorMessageId = useRef(`select2-error-${Math.random().toString(36).substr(2, 9)}`);\n const descriptionId = useRef(`select2-desc-${Math.random().toString(36).substr(2, 9)}`);\n\n // Debounce search\n useEffect(() => {\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n debounceTimerRef.current = setTimeout(() => {\n setDebouncedSearch(search);\n }, debounceMs);\n\n return () => {\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n };\n }, [search, debounceMs]);\n\n // Fetch options with error handling and cleanup\n useEffect(() => {\n if (!open) return;\n\n // Check minimum input length\n if (minInput > 0 && debouncedSearch.length < minInput) {\n setOptions([]);\n setLoading(false);\n setError(null);\n return;\n }\n\n // Cancel previous request\n if (abortControllerRef.current) {\n abortControllerRef.current.abort();\n }\n\n const controller = new AbortController();\n abortControllerRef.current = controller;\n\n setLoading(true);\n setError(null);\n setOptions([]);\n setPage(1);\n\n fetchOptions({ search: debouncedSearch, page: 1 })\n .then((res) => {\n if (!controller.signal.aborted) {\n setOptions(res.data);\n setHasMore(res.hasMore);\n setLoading(false);\n // Announce results for screen readers\n const count = res.data.length;\n setAnnouncement(\n count === 0\n ? \"Tidak ada hasil ditemukan\"\n : `${count} hasil ditemukan`\n );\n }\n })\n .catch((err) => {\n if (!controller.signal.aborted) {\n setError(err instanceof Error ? err : new Error(String(err)));\n setLoading(false);\n setAnnouncement(\"Terjadi kesalahan saat memuat data\");\n }\n });\n\n return () => {\n controller.abort();\n };\n }, [debouncedSearch, open, fetchOptions, minInput]);\n\n // Load more options with error handling\n const loadMore = useCallback(() => {\n setLoadingMore(true);\n setError(null);\n fetchOptions({ search: debouncedSearch, page: page + 1 })\n .then((res) => {\n setOptions((prev) => [...prev, ...res.data]);\n setHasMore(res.hasMore);\n setPage((prev) => prev + 1);\n setLoadingMore(false);\n })\n .catch((err) => {\n setError(err instanceof Error ? err : new Error(String(err)));\n setLoadingMore(false);\n });\n }, [debouncedSearch, page, fetchOptions]);\n\n // Sync selected from parent\n useEffect(() => {\n setSelected(value);\n }, [value]);\n\n // Handle select with max selection check\n const handleSelect = useCallback((item: T) => {\n if (isMulti) {\n const arr = Array.isArray(selected) ? [...selected] : [];\n const idx = arr.findIndex((x) => x.id === item.id);\n \n if (idx > -1) {\n // Remove item\n arr.splice(idx, 1);\n setSelected(arr);\n onChange(arr);\n setAnnouncement(`${item.label ?? item.id} dihapus dari pilihan`);\n } else {\n // Add item - check max selections\n if (maxSelections && arr.length >= maxSelections) {\n setAnnouncement(`Maksimal ${maxSelections} item dapat dipilih`);\n onMaxSelectionsReached?.();\n return;\n }\n arr.push(item);\n setSelected(arr);\n onChange(arr);\n setAnnouncement(`${item.label ?? item.id} ditambahkan ke pilihan`);\n }\n } else {\n setSelected(item);\n onChange(item);\n setOpen(false);\n setAnnouncement(`${item.label ?? item.id} dipilih`);\n }\n }, [isMulti, selected, onChange, maxSelections, onMaxSelectionsReached]);\n\n // Handle remove item from multi-select\n const handleRemoveItem = useCallback((item: T, e: React.MouseEvent) => {\n e.stopPropagation();\n const arr = Array.isArray(selected) ? [...selected] : [];\n const filtered = arr.filter((x) => x.id !== item.id);\n setSelected(filtered);\n onChange(filtered);\n setAnnouncement(`${item.label ?? item.id} dihapus`);\n }, [selected, onChange]);\n\n // Handle clear all\n const handleClear = useCallback((e: React.MouseEvent) => {\n e.stopPropagation();\n setSelected(isMulti ? [] : null);\n onChange(isMulti ? [] : null);\n setAnnouncement(\"Semua pilihan dihapus\");\n }, [isMulti, onChange]);\n\n // Handle select all for multi-select\n const handleSelectAll = useCallback(() => {\n if (!isMulti) return;\n \n const currentSelected = Array.isArray(selected) ? selected : [];\n const allOptions = options;\n \n // Check if applying max selections\n const itemsToAdd = maxSelections\n ? allOptions.slice(0, Math.max(0, maxSelections - currentSelected.length))\n : allOptions;\n \n // Merge with existing, avoiding duplicates\n const existingIds = new Set(currentSelected.map(item => item.id));\n const newItems = itemsToAdd.filter(item => !existingIds.has(item.id));\n const merged = [...currentSelected, ...newItems];\n \n setSelected(merged);\n onChange(merged);\n \n if (maxSelections && merged.length >= maxSelections) {\n setAnnouncement(`${merged.length} item dipilih (maksimal ${maxSelections})`);\n } else {\n setAnnouncement(`${merged.length} item dipilih`);\n }\n }, [isMulti, selected, options, onChange, maxSelections]);\n\n // Handle clear all selections in multi-select\n const handleClearAllSelections = useCallback(() => {\n if (!isMulti) return;\n setSelected([]);\n onChange([]);\n setAnnouncement(\"Semua pilihan dihapus\");\n }, [isMulti, onChange]);\n\n // Keyboard handler for backspace to remove last item\n const handleKeyDown = useCallback((e: React.KeyboardEvent) => {\n if (!isMulti || !open) return;\n \n // Backspace to remove last selected item when search is empty\n if (e.key === \"Backspace\" && search === \"\") {\n const arr = Array.isArray(selected) ? selected : [];\n if (arr.length > 0) {\n const lastItem = arr[arr.length - 1];\n const filtered = arr.slice(0, -1);\n setSelected(filtered);\n onChange(filtered);\n setAnnouncement(`${lastItem.label ?? lastItem.id} dihapus`);\n e.preventDefault();\n }\n }\n }, [isMulti, open, search, selected, onChange]);\n\n // Check if item is selected\n const isItemSelected = useCallback((item: T): boolean => {\n if (isMulti) {\n return Array.isArray(selected) && selected.some((x) => x.id === item.id);\n }\n return selected !== null && (selected as T).id === item.id;\n }, [isMulti, selected]);\n\n // Get selected count for multi-select\n const selectedCount = isMulti && Array.isArray(selected) ? selected.length : 0;\n\n // Check if max selections reached\n const isMaxReached = maxSelections ? selectedCount >= maxSelections : false;\n\n // Render selected with proper typing\n const renderSelectedValue = () => {\n if (renderSelected) return renderSelected(selected);\n if (isMulti) {\n const arr = Array.isArray(selected) ? selected : [];\n if (arr.length === 0)\n return <span className=\"text-muted-foreground\">{placeholder}</span>;\n return arr.map((item: T) => (\n <span\n key={item.id}\n className=\"inline-flex items-center gap-1 bg-muted rounded px-2 py-0.5 mr-1 text-xs\"\n >\n <span>{item.label ?? item.id}</span>\n {!disabled && (\n <button\n type=\"button\"\n onClick={(e) => handleRemoveItem(item, e)}\n className=\"hover:bg-muted-foreground/20 rounded-full p-0.5\"\n aria-label={`Remove ${item.label ?? item.id}`}\n >\n <XIcon className=\"h-3 w-3\" />\n </button>\n )}\n </span>\n ));\n }\n if (!selected)\n return <span className=\"text-muted-foreground\">{placeholder}</span>;\n const singleItem = selected as T;\n return <span>{singleItem.label ?? singleItem.id}</span>;\n };\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <div className=\"relative w-full\">\n {/* Screen reader announcements */}\n <div\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n className=\"sr-only\"\n >\n {announcement}\n </div>\n <PopoverTrigger asChild>\n <Button\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n aria-haspopup=\"listbox\"\n aria-controls=\"select2-listbox\"\n aria-describedby={error ? errorMessageId.current : descriptionId.current}\n className={cn(\"w-full min-w-[200px] justify-between\", className)}\n disabled={disabled}\n >\n <span className=\"truncate flex-1 text-left\">\n {renderSelectedValue()}\n </span>\n <ChevronsUpDown className=\"ml-2 h-4 w-4 shrink-0 opacity-50\" />\n </Button>\n </PopoverTrigger>\n {selected && !disabled && (\n <button\n type=\"button\"\n tabIndex={-1}\n className=\"absolute right-8 top-1/2 -translate-y-1/2 z-10 p-0.5 rounded hover:bg-muted/70 focus:outline-none\"\n onClick={handleClear}\n aria-label=\"Clear selection\"\n >\n <XIcon className=\"h-4 w-4 text-muted-foreground\" />\n </button>\n )}\n </div>\n <PopoverContent className=\"w-[300px] p-0\" id=\"select2-listbox\">\n <Command onKeyDown={handleKeyDown}>\n <CommandInput\n placeholder={placeholder || \"Cari...\"}\n value={search}\n onValueChange={setSearch}\n className=\"h-9\"\n autoFocus\n aria-label=\"Cari opsi\"\n aria-describedby={descriptionId.current}\n />\n <span id={descriptionId.current} className=\"sr-only\">\n {isMulti\n ? `Mode multi-select. ${selectedCount} item dipilih${maxSelections ? `, maksimal ${maxSelections}` : \"\"}. Gunakan arrow keys untuk navigasi, Enter untuk memilih, Backspace untuk menghapus item terakhir.`\n : \"Gunakan arrow keys untuk navigasi, Enter untuk memilih.\"}\n {minInput > 0 && ` Minimal ${minInput} karakter untuk mencari.`}\n </span>\n <CommandList>\n {loading ? (\n <div className=\"flex items-center justify-center py-6 text-muted-foreground text-sm\">\n <Loader2 className=\"animate-spin w-4 h-4 mr-2\" />{\" \"}\n {loadingMessage}\n </div>\n ) : null}\n {error && !loading ? (\n <div\n className=\"flex flex-col items-center justify-center py-6 text-destructive text-sm px-4\"\n role=\"alert\"\n aria-live=\"assertive\"\n id={errorMessageId.current}\n >\n <p className=\"text-center\">\n {typeof errorMessage === \"function\"\n ? errorMessage(error)\n : errorMessage}\n </p>\n <Button\n size=\"sm\"\n variant=\"outline\"\n onClick={() => {\n setError(null);\n setDebouncedSearch(search);\n }}\n className=\"mt-2\"\n >\n Coba Lagi\n </Button>\n </div>\n ) : null}\n <CommandEmpty>\n {minInput > 0 && search.length < minInput ? (\n <div className=\"py-6 text-center text-sm text-muted-foreground\">\n Ketik minimal {minInput} karakter untuk mencari\n </div>\n ) : (\n typeof noOptionsMessage === \"function\"\n ? noOptionsMessage(search)\n : noOptionsMessage\n )}\n </CommandEmpty>\n {isMulti && showSelectAll && options.length > 0 && !loading && !error && (\n <div className=\"flex items-center gap-2 px-2 py-1.5 border-b\">\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={handleSelectAll}\n disabled={isMaxReached}\n className=\"flex-1 h-7 text-xs\"\n type=\"button\"\n >\n Pilih Semua{maxSelections ? ` (Max ${maxSelections})` : \"\"}\n </Button>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={handleClearAllSelections}\n disabled={selectedCount === 0}\n className=\"flex-1 h-7 text-xs\"\n type=\"button\"\n >\n Hapus Semua\n </Button>\n </div>\n )}\n <CommandGroup role=\"listbox\">\n {options.map((item) => {\n const itemSelected = isItemSelected(item);\n const canSelect = !isMulti || !isMaxReached || itemSelected;\n \n return (\n <CommandItem\n key={item.id}\n value={item.label ?? String(item.id)}\n onSelect={() => canSelect && handleSelect(item)}\n disabled={!canSelect}\n className={cn(\n itemSelected ? \"bg-muted font-semibold\" : \"\",\n !canSelect && \"opacity-50 cursor-not-allowed\"\n )}\n role=\"option\"\n aria-selected={itemSelected}\n >\n {renderOption ? renderOption(item) : item.label ?? item.id}\n <Check\n className={cn(\n \"ml-auto h-4 w-4\",\n itemSelected ? \"opacity-100\" : \"opacity-0\"\n )}\n aria-hidden=\"true\"\n />\n </CommandItem>\n );\n })}\n </CommandGroup>\n {hasMore && !loading && (\n <div className=\"flex items-center justify-center py-2\">\n <Button\n