@djangocfg/ui-core 2.1.320 → 2.1.322

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,127 @@
1
+ import React from 'react';
2
+
3
+ import { Link } from '../link';
4
+ import {
5
+ Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
6
+ BreadcrumbSeparator
7
+ } from './breadcrumb';
8
+
9
+ export interface BreadcrumbItem {
10
+ label: string;
11
+ href?: string;
12
+ /**
13
+ * Force render as current page (non-interactive, aria-current="page").
14
+ * By default only the last item without href is treated as current page.
15
+ */
16
+ isCurrentPage?: boolean;
17
+ icon?: React.ReactNode;
18
+ }
19
+
20
+ export interface BreadcrumbNavigationProps {
21
+ items: BreadcrumbItem[];
22
+ separator?: React.ReactNode;
23
+ /**
24
+ * Max visible items (excluding the ellipsis slot) before collapsing.
25
+ * Must be >= 2. Defaults to 5.
26
+ */
27
+ maxItems?: number;
28
+ className?: string;
29
+ }
30
+
31
+ type DisplayEntry =
32
+ | { type: 'item'; item: BreadcrumbItem; originalIndex: number }
33
+ | { type: 'ellipsis' };
34
+
35
+ export const BreadcrumbNavigation: React.FC<BreadcrumbNavigationProps> = ({
36
+ items,
37
+ separator,
38
+ maxItems = 5,
39
+ className,
40
+ }) => {
41
+ if (!items || items.length === 0) return null;
42
+
43
+ const clampedMax = Math.max(2, maxItems);
44
+
45
+ const entries: DisplayEntry[] = (() => {
46
+ if (items.length <= clampedMax) {
47
+ return items.map((item, i) => ({ type: 'item' as const, item, originalIndex: i }));
48
+ }
49
+ const tailCount = Math.max(1, clampedMax - 2);
50
+ const tail = items.slice(-tailCount).map((item, i) => ({
51
+ type: 'item' as const,
52
+ item,
53
+ originalIndex: items.length - tailCount + i,
54
+ }));
55
+ return [
56
+ { type: 'item' as const, item: items[0]!, originalIndex: 0 },
57
+ { type: 'ellipsis' as const },
58
+ ...tail,
59
+ ];
60
+ })();
61
+
62
+ const resolveIsCurrentPage = (item: BreadcrumbItem, originalIndex: number): boolean => {
63
+ if (item.isCurrentPage !== undefined) return item.isCurrentPage;
64
+ return originalIndex === items.length - 1;
65
+ };
66
+
67
+ const renderItem = (item: BreadcrumbItem, originalIndex: number) => {
68
+ const current = resolveIsCurrentPage(item, originalIndex);
69
+
70
+ const content = (
71
+ <>
72
+ {item.icon && <span className="mr-1">{item.icon}</span>}
73
+ {item.label}
74
+ </>
75
+ );
76
+
77
+ if (current) {
78
+ return (
79
+ <BreadcrumbItem key={`item-${originalIndex}`}>
80
+ <BreadcrumbPage>{content}</BreadcrumbPage>
81
+ </BreadcrumbItem>
82
+ );
83
+ }
84
+
85
+ return (
86
+ <BreadcrumbItem key={`item-${originalIndex}`}>
87
+ {item.href ? (
88
+ <BreadcrumbLink asChild>
89
+ <Link href={item.href}>{content}</Link>
90
+ </BreadcrumbLink>
91
+ ) : (
92
+ <BreadcrumbLink>{content}</BreadcrumbLink>
93
+ )}
94
+ </BreadcrumbItem>
95
+ );
96
+ };
97
+
98
+ return (
99
+ <Breadcrumb className={className}>
100
+ <BreadcrumbList>
101
+ {entries.map((entry, displayIndex) => {
102
+ const isFirst = displayIndex === 0;
103
+
104
+ if (entry.type === 'ellipsis') {
105
+ return (
106
+ <React.Fragment key="ellipsis">
107
+ <BreadcrumbSeparator>{separator}</BreadcrumbSeparator>
108
+ <BreadcrumbItem>
109
+ <BreadcrumbEllipsis />
110
+ </BreadcrumbItem>
111
+ </React.Fragment>
112
+ );
113
+ }
114
+
115
+ return (
116
+ <React.Fragment key={`fragment-${entry.originalIndex}`}>
117
+ {!isFirst && <BreadcrumbSeparator>{separator}</BreadcrumbSeparator>}
118
+ {renderItem(entry.item, entry.originalIndex)}
119
+ </React.Fragment>
120
+ );
121
+ })}
122
+ </BreadcrumbList>
123
+ </Breadcrumb>
124
+ );
125
+ };
126
+
127
+ BreadcrumbNavigation.displayName = 'BreadcrumbNavigation';
@@ -0,0 +1,133 @@
1
+ "use client"
2
+
3
+ import { ChevronRight, MoreHorizontal } from 'lucide-react';
4
+ import * as React from 'react';
5
+
6
+ import { cn } from '../../../lib';
7
+ import { Slot } from '@radix-ui/react-slot';
8
+ import { Link } from '../link';
9
+
10
+ const Breadcrumb = React.forwardRef<
11
+ HTMLElement,
12
+ React.ComponentPropsWithoutRef<"nav"> & {
13
+ separator?: React.ReactNode
14
+ }
15
+ >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
16
+ Breadcrumb.displayName = "Breadcrumb"
17
+
18
+ const BreadcrumbList = React.forwardRef<
19
+ HTMLOListElement,
20
+ React.ComponentPropsWithoutRef<"ol">
21
+ >(({ className, ...props }, ref) => (
22
+ <ol
23
+ ref={ref}
24
+ className={cn(
25
+ "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
26
+ className
27
+ )}
28
+ {...props}
29
+ />
30
+ ))
31
+ BreadcrumbList.displayName = "BreadcrumbList"
32
+
33
+ const BreadcrumbItem = React.forwardRef<
34
+ HTMLLIElement,
35
+ React.ComponentPropsWithoutRef<"li"> & { key?: React.Key }
36
+ >(({ className, ...props }, ref) => (
37
+ <li
38
+ ref={ref}
39
+ className={cn("inline-flex items-center gap-1.5", className)}
40
+ {...props}
41
+ />
42
+ ))
43
+ BreadcrumbItem.displayName = "BreadcrumbItem"
44
+
45
+ const BreadcrumbLink = React.forwardRef<
46
+ HTMLAnchorElement,
47
+ React.ComponentPropsWithoutRef<"a"> & {
48
+ asChild?: boolean
49
+ href?: string
50
+ }
51
+ >(({ asChild, className, href, children, ...props }, ref) => {
52
+ const Comp = asChild ? Slot : "a"
53
+
54
+ if (href) {
55
+ return (
56
+ <Link
57
+ href={href}
58
+ className={cn("transition-colors hover:text-foreground", className)}
59
+ ref={ref}
60
+ >
61
+ {children}
62
+ </Link>
63
+ )
64
+ }
65
+
66
+ return (
67
+ <Comp
68
+ ref={ref}
69
+ className={cn("transition-colors hover:text-foreground", className)}
70
+ {...props}
71
+ >
72
+ {children}
73
+ </Comp>
74
+ )
75
+ })
76
+ BreadcrumbLink.displayName = "BreadcrumbLink"
77
+
78
+ const BreadcrumbPage = React.forwardRef<
79
+ HTMLSpanElement,
80
+ React.ComponentPropsWithoutRef<"span">
81
+ >(({ className, ...props }, ref) => (
82
+ <span
83
+ ref={ref}
84
+ role="link"
85
+ aria-disabled="true"
86
+ aria-current="page"
87
+ className={cn("font-normal text-foreground", className)}
88
+ {...props}
89
+ />
90
+ ))
91
+ BreadcrumbPage.displayName = "BreadcrumbPage"
92
+
93
+ const BreadcrumbSeparator = ({
94
+ children,
95
+ className,
96
+ ...props
97
+ }: React.ComponentProps<"li"> & { key?: React.Key }) => (
98
+ <li
99
+ role="presentation"
100
+ aria-hidden="true"
101
+ className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
102
+ {...props}
103
+ >
104
+ {children ?? <ChevronRight />}
105
+ </li>
106
+ )
107
+ BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
108
+
109
+ const BreadcrumbEllipsis = ({
110
+ className,
111
+ ...props
112
+ }: React.ComponentProps<"span">) => (
113
+ <span
114
+ role="presentation"
115
+ aria-hidden="true"
116
+ className={cn("flex h-9 w-9 items-center justify-center", className)}
117
+ {...props}
118
+ >
119
+ <MoreHorizontal className="h-4 w-4" />
120
+ <span className="sr-only">More</span>
121
+ </span>
122
+ )
123
+ BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
124
+
125
+ export {
126
+ Breadcrumb,
127
+ BreadcrumbList,
128
+ BreadcrumbItem,
129
+ BreadcrumbLink,
130
+ BreadcrumbPage,
131
+ BreadcrumbSeparator,
132
+ BreadcrumbEllipsis,
133
+ }
@@ -0,0 +1,15 @@
1
+ export {
2
+ Breadcrumb,
3
+ BreadcrumbEllipsis,
4
+ BreadcrumbItem,
5
+ BreadcrumbLink,
6
+ BreadcrumbList,
7
+ BreadcrumbPage,
8
+ BreadcrumbSeparator,
9
+ } from './breadcrumb';
10
+
11
+ export { BreadcrumbNavigation } from './breadcrumb-navigation';
12
+ export type {
13
+ BreadcrumbItem as BreadcrumbNavigationItem,
14
+ BreadcrumbNavigationProps,
15
+ } from './breadcrumb-navigation';
@@ -0,0 +1,18 @@
1
+ export {
2
+ Pagination,
3
+ PaginationContent,
4
+ PaginationEllipsis,
5
+ PaginationItem,
6
+ PaginationLink,
7
+ PaginationNext,
8
+ PaginationPrevious,
9
+ } from './pagination';
10
+
11
+ export {
12
+ StaticPagination,
13
+ useDRFPagination,
14
+ useDRFPaginationInfo,
15
+ } from './pagination-static';
16
+ export type { DRFPaginatedResponse } from './pagination-static';
17
+
18
+ export { SSRPagination } from './ssr-pagination';
@@ -0,0 +1,249 @@
1
+ /**
2
+ * DRF Static Pagination Component
3
+ *
4
+ * Universal pagination component that works with Django REST Framework pagination format.
5
+ * Manages page state internally and provides callbacks for page changes.
6
+ */
7
+
8
+ "use client"
9
+
10
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
11
+ import React from 'react';
12
+
13
+ import { cn } from '../../../lib';
14
+ import { Button } from '../../forms/button';
15
+ import { useIsMobile } from '../../../hooks';
16
+
17
+ import {
18
+ Pagination, PaginationContent, PaginationEllipsis, PaginationItem
19
+ } from './pagination';
20
+
21
+ export interface DRFPaginatedResponse<T = any> {
22
+ count: number;
23
+ page: number;
24
+ pages: number;
25
+ page_size: number;
26
+ has_next: boolean;
27
+ has_previous: boolean;
28
+ next_page?: number | null;
29
+ previous_page?: number | null;
30
+ results: T[];
31
+ }
32
+
33
+ interface StaticPaginationProps {
34
+ data?: DRFPaginatedResponse | null;
35
+ onPageChange: (page: number) => void;
36
+ className?: string;
37
+ showInfo?: boolean;
38
+ maxVisiblePages?: number;
39
+ }
40
+
41
+ export const StaticPagination: React.FC<StaticPaginationProps> = ({
42
+ data,
43
+ onPageChange,
44
+ className,
45
+ showInfo = true,
46
+ maxVisiblePages = 7,
47
+ }) => {
48
+ const isMobile = useIsMobile();
49
+
50
+ if (!data || !('count' in data) || !('page' in data) || !('pages' in data)) {
51
+ return null;
52
+ }
53
+
54
+ const {
55
+ count: totalItems,
56
+ page: currentPage,
57
+ pages: totalPages,
58
+ page_size: itemsPerPage,
59
+ has_next: hasNextPage,
60
+ has_previous: hasPreviousPage,
61
+ } = data;
62
+
63
+ if (totalPages <= 1) {
64
+ return null;
65
+ }
66
+
67
+ const getVisiblePages = (): (number | 'ellipsis')[] => {
68
+ const mobileMaxVisible = 3;
69
+ const effectiveMaxVisible = isMobile ? mobileMaxVisible : maxVisiblePages;
70
+
71
+ if (totalPages <= effectiveMaxVisible) {
72
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
73
+ }
74
+
75
+ const pages: (number | 'ellipsis')[] = [];
76
+ const halfVisible = Math.floor(effectiveMaxVisible / 2);
77
+
78
+ if (isMobile) {
79
+ if (currentPage > 1) {
80
+ pages.push(currentPage - 1);
81
+ }
82
+ pages.push(currentPage);
83
+ if (currentPage < totalPages) {
84
+ pages.push(currentPage + 1);
85
+ }
86
+ } else {
87
+ pages.push(1);
88
+
89
+ let start = Math.max(2, currentPage - halfVisible);
90
+ let end = Math.min(totalPages - 1, currentPage + halfVisible);
91
+
92
+ if (currentPage <= halfVisible + 1) {
93
+ end = Math.min(totalPages - 1, effectiveMaxVisible - 1);
94
+ } else if (currentPage >= totalPages - halfVisible) {
95
+ start = Math.max(2, totalPages - effectiveMaxVisible + 2);
96
+ }
97
+
98
+ if (start > 2) {
99
+ pages.push('ellipsis');
100
+ }
101
+
102
+ for (let i = start; i <= end; i++) {
103
+ pages.push(i);
104
+ }
105
+
106
+ if (end < totalPages - 1) {
107
+ pages.push('ellipsis');
108
+ }
109
+
110
+ if (totalPages > 1) {
111
+ pages.push(totalPages);
112
+ }
113
+ }
114
+
115
+ return pages;
116
+ };
117
+
118
+ const visiblePages = getVisiblePages();
119
+ const startItem = (currentPage - 1) * itemsPerPage + 1;
120
+ const endItem = Math.min(currentPage * itemsPerPage, totalItems);
121
+
122
+ const handlePageClick = (page: number) => {
123
+ if (page !== currentPage && page >= 1 && page <= totalPages) {
124
+ onPageChange(page);
125
+ }
126
+ };
127
+
128
+ return (
129
+ <div className={cn("space-y-4", className)}>
130
+ {showInfo && (
131
+ <div className="text-sm text-muted-foreground text-center">
132
+ {isMobile ? (
133
+ `Page ${currentPage} of ${totalPages}`
134
+ ) : (
135
+ `Showing ${startItem.toLocaleString()} to ${endItem.toLocaleString()} of ${totalItems.toLocaleString()} results`
136
+ )}
137
+ </div>
138
+ )}
139
+
140
+ <Pagination>
141
+ <PaginationContent>
142
+ <PaginationItem>
143
+ <Button
144
+ variant="ghost"
145
+ size="default"
146
+ onClick={() => handlePageClick(currentPage - 1)}
147
+ disabled={!hasPreviousPage}
148
+ className={cn(
149
+ "gap-1 pl-2.5",
150
+ !hasPreviousPage && "pointer-events-none opacity-50"
151
+ )}
152
+ aria-label="Go to previous page"
153
+ >
154
+ <ChevronLeft className="h-4 w-4" />
155
+ <span>Previous</span>
156
+ </Button>
157
+ </PaginationItem>
158
+
159
+ {visiblePages.map((page, index) => (
160
+ <PaginationItem key={index}>
161
+ {page === 'ellipsis' ? (
162
+ <PaginationEllipsis />
163
+ ) : (
164
+ <Button
165
+ variant={page === currentPage ? "outline" : "ghost"}
166
+ size="icon"
167
+ onClick={() => handlePageClick(page)}
168
+ aria-current={page === currentPage ? "page" : undefined}
169
+ className={cn(
170
+ page === currentPage && "pointer-events-none"
171
+ )}
172
+ >
173
+ {page}
174
+ </Button>
175
+ )}
176
+ </PaginationItem>
177
+ ))}
178
+
179
+ <PaginationItem>
180
+ <Button
181
+ variant="ghost"
182
+ size="default"
183
+ onClick={() => handlePageClick(currentPage + 1)}
184
+ disabled={!hasNextPage}
185
+ className={cn(
186
+ "gap-1 pr-2.5",
187
+ !hasNextPage && "pointer-events-none opacity-50"
188
+ )}
189
+ aria-label="Go to next page"
190
+ >
191
+ <span>Next</span>
192
+ <ChevronRight className="h-4 w-4" />
193
+ </Button>
194
+ </PaginationItem>
195
+ </PaginationContent>
196
+ </Pagination>
197
+ </div>
198
+ );
199
+ };
200
+
201
+ StaticPagination.displayName = 'StaticPagination';
202
+
203
+ export function useDRFPagination(initialPage = 1, initialPageSize = 10) {
204
+ const [page, setPage] = React.useState(initialPage);
205
+ const [pageSize, setPageSize] = React.useState(initialPageSize);
206
+
207
+ const handlePageSizeChange = React.useCallback((newPageSize: number) => {
208
+ setPageSize(newPageSize);
209
+ setPage(1);
210
+ }, []);
211
+
212
+ const params = React.useMemo(() => ({
213
+ page,
214
+ page_size: pageSize,
215
+ }), [page, pageSize]);
216
+
217
+ return {
218
+ page,
219
+ pageSize,
220
+ setPage,
221
+ setPageSize: handlePageSizeChange,
222
+ params,
223
+ reset: () => {
224
+ setPage(initialPage);
225
+ setPageSize(initialPageSize);
226
+ },
227
+ };
228
+ }
229
+
230
+ export function useDRFPaginationInfo(data?: DRFPaginatedResponse | null) {
231
+ return React.useMemo(() => {
232
+ if (!data || !('count' in data) || !('page' in data)) {
233
+ return null;
234
+ }
235
+
236
+ return {
237
+ totalItems: data.count,
238
+ currentPage: data.page,
239
+ totalPages: data.pages,
240
+ itemsPerPage: data.page_size,
241
+ hasNext: data.has_next,
242
+ hasPrevious: data.has_previous,
243
+ nextPage: data.next_page,
244
+ previousPage: data.previous_page,
245
+ results: data.results || [],
246
+ resultsCount: data.results?.length || 0,
247
+ };
248
+ }, [data]);
249
+ }
@@ -0,0 +1,139 @@
1
+ import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
2
+ import * as React from 'react';
3
+
4
+ import { cn } from '../../../lib';
5
+ import { type ButtonProps, buttonVariants } from '../../forms/button';
6
+ import { Link } from '../link';
7
+
8
+ const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
9
+ <nav
10
+ role="navigation"
11
+ aria-label="pagination"
12
+ className={cn("mx-auto flex w-full justify-center", className)}
13
+ {...props}
14
+ />
15
+ )
16
+ Pagination.displayName = "Pagination"
17
+
18
+ const PaginationContent = React.forwardRef<
19
+ HTMLUListElement,
20
+ React.ComponentProps<"ul">
21
+ >(({ className, ...props }, ref) => (
22
+ <ul
23
+ ref={ref}
24
+ className={cn("flex flex-row items-center gap-1", className)}
25
+ {...props}
26
+ />
27
+ ))
28
+ PaginationContent.displayName = "PaginationContent"
29
+
30
+ const PaginationItem = React.forwardRef<
31
+ HTMLLIElement,
32
+ React.ComponentProps<"li"> & { key?: React.Key }
33
+ >(({ className, ...props }, ref) => (
34
+ <li ref={ref} className={cn("", className)} {...props} />
35
+ ))
36
+ PaginationItem.displayName = "PaginationItem"
37
+
38
+ type PaginationLinkProps = {
39
+ isActive?: boolean
40
+ href?: string
41
+ } & Pick<ButtonProps, "size"> &
42
+ React.ComponentProps<"a">
43
+
44
+ const PaginationLink = ({
45
+ className,
46
+ isActive,
47
+ size = "icon",
48
+ href,
49
+ children,
50
+ ...props
51
+ }: PaginationLinkProps) => {
52
+ const classes = cn(
53
+ buttonVariants({
54
+ variant: isActive ? "outline" : "ghost",
55
+ size,
56
+ }),
57
+ className
58
+ )
59
+
60
+ if (href) {
61
+ return (
62
+ <Link
63
+ href={href}
64
+ aria-current={isActive ? "page" : undefined}
65
+ className={classes}
66
+ >
67
+ {children}
68
+ </Link>
69
+ )
70
+ }
71
+
72
+ return (
73
+ <a
74
+ aria-current={isActive ? "page" : undefined}
75
+ className={classes}
76
+ {...props}
77
+ >
78
+ {children}
79
+ </a>
80
+ )
81
+ }
82
+ PaginationLink.displayName = "PaginationLink"
83
+
84
+ const PaginationPrevious = ({
85
+ className,
86
+ ...props
87
+ }: React.ComponentProps<typeof PaginationLink>) => (
88
+ <PaginationLink
89
+ aria-label="Go to previous page"
90
+ size="default"
91
+ className={cn("gap-1 pl-2.5", className)}
92
+ {...props}
93
+ >
94
+ <ChevronLeft className="h-4 w-4" />
95
+ <span>Previous</span>
96
+ </PaginationLink>
97
+ )
98
+ PaginationPrevious.displayName = "PaginationPrevious"
99
+
100
+ const PaginationNext = ({
101
+ className,
102
+ ...props
103
+ }: React.ComponentProps<typeof PaginationLink>) => (
104
+ <PaginationLink
105
+ aria-label="Go to next page"
106
+ size="default"
107
+ className={cn("gap-1 pr-2.5", className)}
108
+ {...props}
109
+ >
110
+ <span>Next</span>
111
+ <ChevronRight className="h-4 w-4" />
112
+ </PaginationLink>
113
+ )
114
+ PaginationNext.displayName = "PaginationNext"
115
+
116
+ const PaginationEllipsis = ({
117
+ className,
118
+ ...props
119
+ }: React.ComponentProps<"span">) => (
120
+ <span
121
+ aria-hidden
122
+ className={cn("flex h-9 w-9 items-center justify-center", className)}
123
+ {...props}
124
+ >
125
+ <MoreHorizontal className="h-4 w-4" />
126
+ <span className="sr-only">More pages</span>
127
+ </span>
128
+ )
129
+ PaginationEllipsis.displayName = "PaginationEllipsis"
130
+
131
+ export {
132
+ Pagination,
133
+ PaginationContent,
134
+ PaginationLink,
135
+ PaginationItem,
136
+ PaginationPrevious,
137
+ PaginationNext,
138
+ PaginationEllipsis,
139
+ }