@fragments-sdk/ui 0.8.7 → 0.8.8

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,360 @@
1
+ import * as React from 'react';
2
+ import styles from './Pagination.module.scss';
3
+ import '../../styles/globals.scss';
4
+
5
+ // ============================================
6
+ // Types
7
+ // ============================================
8
+
9
+ export interface PaginationProps {
10
+ children: React.ReactNode;
11
+ /** Total number of pages. Clamped to Math.max(0, totalPages). Renders nothing when 0. */
12
+ totalPages: number;
13
+ /** Controlled current page (1-indexed). Clamped to [1, totalPages]. */
14
+ page?: number;
15
+ /** Default page (uncontrolled). Clamped to [1, totalPages]. Default: 1 */
16
+ defaultPage?: number;
17
+ /** Called when page changes */
18
+ onPageChange?: (page: number) => void;
19
+ /** Number of pages shown at edges: default 1 */
20
+ edgeCount?: number;
21
+ /** Number of pages shown around current: default 1 */
22
+ siblingCount?: number;
23
+ className?: string;
24
+ }
25
+
26
+ export interface PaginationItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
27
+ children?: React.ReactNode;
28
+ /** Override page number (auto-assigned by context if omitted) */
29
+ page?: number;
30
+ className?: string;
31
+ }
32
+
33
+ // ============================================
34
+ // Context
35
+ // ============================================
36
+
37
+ interface PaginationContextValue {
38
+ currentPage: number;
39
+ totalPages: number;
40
+ edgeCount: number;
41
+ siblingCount: number;
42
+ setPage: (page: number) => void;
43
+ }
44
+
45
+ const PaginationContext = React.createContext<PaginationContextValue | null>(null);
46
+
47
+ function usePaginationContext() {
48
+ const ctx = React.useContext(PaginationContext);
49
+ if (!ctx) throw new Error('Pagination sub-components must be used within <Pagination>');
50
+ return ctx;
51
+ }
52
+
53
+ // ============================================
54
+ // Page range algorithm
55
+ // ============================================
56
+
57
+ type RangeItem = number | 'ellipsis';
58
+
59
+ function usePaginationRange(
60
+ totalPages: number,
61
+ currentPage: number,
62
+ siblingCount: number,
63
+ edgeCount: number,
64
+ ): RangeItem[] {
65
+ return React.useMemo(() => {
66
+ if (totalPages <= 0) return [];
67
+
68
+ // If total pages is small enough, show all pages
69
+ const totalSlots = edgeCount * 2 + siblingCount * 2 + 1 + 2; // edges + siblings + current + 2 ellipses
70
+ if (totalPages <= totalSlots) {
71
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
72
+ }
73
+
74
+ const leftEdge = Array.from({ length: edgeCount }, (_, i) => i + 1);
75
+ const rightEdge = Array.from({ length: edgeCount }, (_, i) => totalPages - edgeCount + 1 + i);
76
+
77
+ const siblingStart = Math.max(edgeCount + 1, currentPage - siblingCount);
78
+ const siblingEnd = Math.min(totalPages - edgeCount, currentPage + siblingCount);
79
+
80
+ const result: RangeItem[] = [];
81
+
82
+ // Add left edge
83
+ for (const p of leftEdge) {
84
+ result.push(p);
85
+ }
86
+
87
+ // Left ellipsis
88
+ if (siblingStart > edgeCount + 1) {
89
+ // If there's only one gap, show the number instead of ellipsis
90
+ if (siblingStart === edgeCount + 2) {
91
+ result.push(edgeCount + 1);
92
+ } else {
93
+ result.push('ellipsis');
94
+ }
95
+ }
96
+
97
+ // Siblings + current
98
+ for (let i = siblingStart; i <= siblingEnd; i++) {
99
+ if (!result.includes(i)) {
100
+ result.push(i);
101
+ }
102
+ }
103
+
104
+ // Right ellipsis
105
+ if (siblingEnd < totalPages - edgeCount) {
106
+ if (siblingEnd === totalPages - edgeCount - 1) {
107
+ result.push(totalPages - edgeCount);
108
+ } else {
109
+ result.push('ellipsis');
110
+ }
111
+ }
112
+
113
+ // Add right edge
114
+ for (const p of rightEdge) {
115
+ if (!result.includes(p)) {
116
+ result.push(p);
117
+ }
118
+ }
119
+
120
+ return result;
121
+ }, [totalPages, currentPage, siblingCount, edgeCount]);
122
+ }
123
+
124
+ // ============================================
125
+ // Chevron Icons
126
+ // ============================================
127
+
128
+ function ChevronLeftIcon() {
129
+ return (
130
+ <svg
131
+ xmlns="http://www.w3.org/2000/svg"
132
+ width="16"
133
+ height="16"
134
+ viewBox="0 0 24 24"
135
+ fill="none"
136
+ stroke="currentColor"
137
+ strokeWidth="2"
138
+ strokeLinecap="round"
139
+ strokeLinejoin="round"
140
+ aria-hidden="true"
141
+ >
142
+ <polyline points="15 18 9 12 15 6" />
143
+ </svg>
144
+ );
145
+ }
146
+
147
+ function ChevronRightIcon() {
148
+ return (
149
+ <svg
150
+ xmlns="http://www.w3.org/2000/svg"
151
+ width="16"
152
+ height="16"
153
+ viewBox="0 0 24 24"
154
+ fill="none"
155
+ stroke="currentColor"
156
+ strokeWidth="2"
157
+ strokeLinecap="round"
158
+ strokeLinejoin="round"
159
+ aria-hidden="true"
160
+ >
161
+ <polyline points="9 18 15 12 9 6" />
162
+ </svg>
163
+ );
164
+ }
165
+
166
+ // ============================================
167
+ // Components
168
+ // ============================================
169
+
170
+ function clamp(value: number, min: number, max: number) {
171
+ return Math.max(min, Math.min(max, value));
172
+ }
173
+
174
+ function PaginationRoot({
175
+ children,
176
+ totalPages: rawTotalPages,
177
+ page: controlledPage,
178
+ defaultPage = 1,
179
+ onPageChange,
180
+ edgeCount = 1,
181
+ siblingCount = 1,
182
+ className,
183
+ }: PaginationProps) {
184
+ const totalPages = Math.max(0, Math.floor(rawTotalPages));
185
+ const [uncontrolledPage, setUncontrolledPage] = React.useState(() =>
186
+ totalPages > 0 ? clamp(defaultPage, 1, totalPages) : 1
187
+ );
188
+
189
+ const isControlled = controlledPage !== undefined;
190
+ const currentPage = isControlled
191
+ ? (totalPages > 0 ? clamp(controlledPage, 1, totalPages) : 1)
192
+ : (totalPages > 0 ? clamp(uncontrolledPage, 1, totalPages) : 1);
193
+
194
+ const setPage = React.useCallback(
195
+ (newPage: number) => {
196
+ if (totalPages <= 0) return;
197
+ const clamped = clamp(newPage, 1, totalPages);
198
+ if (clamped === currentPage) return;
199
+ if (!isControlled) {
200
+ setUncontrolledPage(clamped);
201
+ }
202
+ onPageChange?.(clamped);
203
+ },
204
+ [totalPages, currentPage, isControlled, onPageChange]
205
+ );
206
+
207
+ const contextValue = React.useMemo<PaginationContextValue>(
208
+ () => ({ currentPage, totalPages, edgeCount, siblingCount, setPage }),
209
+ [currentPage, totalPages, edgeCount, siblingCount, setPage]
210
+ );
211
+
212
+ if (totalPages <= 0) {
213
+ return <nav aria-label="Pagination" className={className} />;
214
+ }
215
+
216
+ return (
217
+ <PaginationContext.Provider value={contextValue}>
218
+ <nav aria-label="Pagination" className={[styles.pagination, className].filter(Boolean).join(' ')}>
219
+ <ul className={styles.list}>
220
+ {children}
221
+ </ul>
222
+ </nav>
223
+ </PaginationContext.Provider>
224
+ );
225
+ }
226
+
227
+ function PaginationPrevious({ className, ...htmlProps }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
228
+ const { currentPage, setPage } = usePaginationContext();
229
+ const disabled = currentPage <= 1;
230
+
231
+ return (
232
+ <li>
233
+ <button
234
+ type="button"
235
+ aria-label="Go to previous page"
236
+ disabled={disabled}
237
+ onClick={() => setPage(currentPage - 1)}
238
+ className={[styles.item, styles.navButton, disabled && styles.itemDisabled, className].filter(Boolean).join(' ')}
239
+ {...htmlProps}
240
+ >
241
+ <ChevronLeftIcon />
242
+ </button>
243
+ </li>
244
+ );
245
+ }
246
+
247
+ function PaginationNext({ className, ...htmlProps }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
248
+ const { currentPage, totalPages, setPage } = usePaginationContext();
249
+ const disabled = currentPage >= totalPages;
250
+
251
+ return (
252
+ <li>
253
+ <button
254
+ type="button"
255
+ aria-label="Go to next page"
256
+ disabled={disabled}
257
+ onClick={() => setPage(currentPage + 1)}
258
+ className={[styles.item, styles.navButton, disabled && styles.itemDisabled, className].filter(Boolean).join(' ')}
259
+ {...htmlProps}
260
+ >
261
+ <ChevronRightIcon />
262
+ </button>
263
+ </li>
264
+ );
265
+ }
266
+
267
+ function PaginationItems() {
268
+ const { currentPage, totalPages, siblingCount, edgeCount, setPage } = usePaginationContext();
269
+ const range = usePaginationRange(totalPages, currentPage, siblingCount, edgeCount);
270
+ let ellipsisCount = 0;
271
+
272
+ return (
273
+ <>
274
+ {range.map((item) => {
275
+ if (item === 'ellipsis') {
276
+ ellipsisCount++;
277
+ return (
278
+ <li key={`ellipsis-${ellipsisCount}`}>
279
+ <span className={styles.ellipsis} aria-hidden="true">
280
+ &hellip;
281
+ </span>
282
+ </li>
283
+ );
284
+ }
285
+
286
+ const isActive = item === currentPage;
287
+ return (
288
+ <li key={item}>
289
+ <button
290
+ type="button"
291
+ aria-label={`Go to page ${item}`}
292
+ aria-current={isActive ? 'page' : undefined}
293
+ onClick={() => setPage(item)}
294
+ className={[styles.item, isActive && styles.itemActive].filter(Boolean).join(' ')}
295
+ >
296
+ {item}
297
+ </button>
298
+ </li>
299
+ );
300
+ })}
301
+ </>
302
+ );
303
+ }
304
+
305
+ function PaginationItem({
306
+ children,
307
+ page: pageProp,
308
+ className,
309
+ ...htmlProps
310
+ }: PaginationItemProps) {
311
+ const { currentPage, setPage } = usePaginationContext();
312
+ const page = pageProp ?? 1;
313
+ const isActive = page === currentPage;
314
+
315
+ return (
316
+ <li>
317
+ <button
318
+ type="button"
319
+ aria-label={`Go to page ${page}`}
320
+ aria-current={isActive ? 'page' : undefined}
321
+ onClick={() => setPage(page)}
322
+ className={[styles.item, isActive && styles.itemActive, className].filter(Boolean).join(' ')}
323
+ {...htmlProps}
324
+ >
325
+ {children ?? page}
326
+ </button>
327
+ </li>
328
+ );
329
+ }
330
+
331
+ function PaginationEllipsis({ className, ...htmlProps }: React.HTMLAttributes<HTMLSpanElement>) {
332
+ return (
333
+ <li>
334
+ <span className={[styles.ellipsis, className].filter(Boolean).join(' ')} aria-hidden="true" {...htmlProps}>
335
+ &hellip;
336
+ </span>
337
+ </li>
338
+ );
339
+ }
340
+
341
+ // ============================================
342
+ // Export compound component
343
+ // ============================================
344
+
345
+ export const Pagination = Object.assign(PaginationRoot, {
346
+ Previous: PaginationPrevious,
347
+ Next: PaginationNext,
348
+ Items: PaginationItems,
349
+ Item: PaginationItem,
350
+ Ellipsis: PaginationEllipsis,
351
+ });
352
+
353
+ export {
354
+ PaginationRoot,
355
+ PaginationPrevious,
356
+ PaginationNext,
357
+ PaginationItems,
358
+ PaginationItem,
359
+ PaginationEllipsis,
360
+ };
@@ -78,6 +78,30 @@ function TooltipRoot({
78
78
  className,
79
79
  ...htmlProps
80
80
  }: TooltipProps) {
81
+ const renderTrigger = React.useCallback(
82
+ (triggerProps: React.HTMLAttributes<HTMLElement> & { ref?: React.Ref<HTMLElement> }) => {
83
+ const childProps = children.props as Record<string, unknown>;
84
+ const mergedProps: Record<string, unknown> = { ...childProps, ...triggerProps };
85
+
86
+ for (const [key, triggerHandler] of Object.entries(triggerProps)) {
87
+ const childHandler = childProps[key];
88
+ if (
89
+ key.startsWith('on') &&
90
+ typeof triggerHandler === 'function' &&
91
+ typeof childHandler === 'function'
92
+ ) {
93
+ mergedProps[key] = (...args: unknown[]) => {
94
+ (childHandler as (...event: unknown[]) => void)(...args);
95
+ (triggerHandler as (...event: unknown[]) => void)(...args);
96
+ };
97
+ }
98
+ }
99
+
100
+ return React.cloneElement(children, mergedProps);
101
+ },
102
+ [children],
103
+ );
104
+
81
105
  if (disabled) {
82
106
  return children;
83
107
  }
@@ -89,7 +113,7 @@ function TooltipRoot({
89
113
  defaultOpen={defaultOpen}
90
114
  onOpenChange={onOpenChange}
91
115
  >
92
- <BaseTooltip.Trigger render={(props) => React.cloneElement(children, props)} />
116
+ <BaseTooltip.Trigger render={renderTrigger} />
93
117
  <BaseTooltip.Portal>
94
118
  <BaseTooltip.Positioner
95
119
  side={side}
package/src/index.ts CHANGED
@@ -527,7 +527,7 @@ export {
527
527
  } from './components/Chart';
528
528
 
529
529
  // Assets
530
- export { FragmentsLogo, type FragmentsLogoProps } from './assets/fragments-logo';
530
+ export { FragmentsLogo, fragmentsLogoSvg, type FragmentsLogoProps } from './assets/fragments-logo';
531
531
 
532
532
  // NavigationMenu
533
533
  export {
@@ -554,6 +554,39 @@ export {
554
554
  type NavigationMenuMobileSectionProps,
555
555
  } from './components/NavigationMenu';
556
556
 
557
+ // Drawer
558
+ export {
559
+ Drawer,
560
+ type DrawerProps,
561
+ type DrawerContentProps,
562
+ type DrawerTriggerProps,
563
+ type DrawerHeaderProps,
564
+ type DrawerTitleProps,
565
+ type DrawerDescriptionProps,
566
+ type DrawerBodyProps,
567
+ type DrawerFooterProps,
568
+ type DrawerCloseProps,
569
+ } from './components/Drawer';
570
+
571
+ // Pagination
572
+ export {
573
+ Pagination,
574
+ type PaginationProps,
575
+ type PaginationItemProps,
576
+ } from './components/Pagination';
577
+
578
+ // Command
579
+ export {
580
+ Command,
581
+ type CommandProps,
582
+ type CommandInputProps,
583
+ type CommandListProps,
584
+ type CommandItemProps,
585
+ type CommandGroupProps,
586
+ type CommandEmptyProps,
587
+ type CommandSeparatorProps,
588
+ } from './components/Command';
589
+
557
590
  // Accessibility Utilities
558
591
  export {
559
592
  useId,