panelui-native 0.38.0 → 0.40.0

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 (37) hide show
  1. package/README.md +2 -1
  2. package/lib/module/components/heatmap-chart/index.js +113 -6
  3. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  4. package/lib/module/components/pagination/index.js +519 -0
  5. package/lib/module/components/pagination/index.js.map +1 -0
  6. package/lib/module/components/ring-chart/index.js +183 -45
  7. package/lib/module/components/ring-chart/index.js.map +1 -1
  8. package/lib/module/components/steps/index.js +8 -6
  9. package/lib/module/components/steps/index.js.map +1 -1
  10. package/lib/module/components/table/index.js +100 -26
  11. package/lib/module/components/table/index.js.map +1 -1
  12. package/lib/module/components/timeline/index.js +8 -7
  13. package/lib/module/components/timeline/index.js.map +1 -1
  14. package/lib/module/index.js +1 -0
  15. package/lib/module/index.js.map +1 -1
  16. package/lib/typescript/src/components/heatmap-chart/index.d.ts +51 -3
  17. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  18. package/lib/typescript/src/components/pagination/index.d.ts +285 -0
  19. package/lib/typescript/src/components/pagination/index.d.ts.map +1 -0
  20. package/lib/typescript/src/components/ring-chart/index.d.ts +75 -8
  21. package/lib/typescript/src/components/ring-chart/index.d.ts.map +1 -1
  22. package/lib/typescript/src/components/steps/index.d.ts +8 -6
  23. package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
  24. package/lib/typescript/src/components/table/index.d.ts +59 -17
  25. package/lib/typescript/src/components/table/index.d.ts.map +1 -1
  26. package/lib/typescript/src/components/timeline/index.d.ts +8 -7
  27. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  28. package/lib/typescript/src/index.d.ts +2 -1
  29. package/lib/typescript/src/index.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/components/heatmap-chart/index.tsx +147 -5
  32. package/src/components/pagination/index.tsx +591 -0
  33. package/src/components/ring-chart/index.tsx +269 -47
  34. package/src/components/steps/index.tsx +8 -6
  35. package/src/components/table/index.tsx +146 -30
  36. package/src/components/timeline/index.tsx +8 -7
  37. package/src/index.ts +15 -0
@@ -0,0 +1,591 @@
1
+ /**
2
+ * Pagination — moving through a result set one page at a time.
3
+ *
4
+ * The component owns the arithmetic and nothing else. Give it how many pages
5
+ * there are and which one you are on, and it works out which numbers to show,
6
+ * where the gaps fall, and which controls are dead at the ends; it never fetches
7
+ * and never slices, because the data is yours.
8
+ *
9
+ * ```tsx
10
+ * <Pagination count={12} page={page} onPageChange={setPage} />
11
+ * ```
12
+ *
13
+ * Three presentations, because a phone is not a desktop:
14
+ *
15
+ * - `numbers` — the full run, with the middle folded into ellipses. Fits about
16
+ * seven targets across a phone, which is what `siblings` and `boundaries`
17
+ * are tuned for.
18
+ * - `compact` — the two arrows with `3 / 12` between them. Digits and a slash,
19
+ * so there is no sentence to translate.
20
+ * - `simple` — labelled Previous and Next, for a flow you walk rather than
21
+ * jump around in: a wizard, an article, a set of onboarding cards.
22
+ *
23
+ * Every target is at least 44pt in both axes, and the small size keeps that
24
+ * reach with `hitSlop` rather than by growing — the row gets denser, the
25
+ * fingers do not get smaller.
26
+ *
27
+ * An ellipsis is a button, not punctuation. A dead 44pt target in the middle of
28
+ * a row of live ones is a thing people tap and then think is broken, so tapping
29
+ * one jumps `pageJump` pages that way.
30
+ *
31
+ * Given children, the root becomes a row with them on the leading edge and the
32
+ * controls on the trailing one — which is where `Pagination.Status` goes:
33
+ *
34
+ * ```tsx
35
+ * <Pagination count={12} page={page} onPageChange={setPage} variant="compact">
36
+ * <Pagination.Status page={page} pageSize={20} total={240} />
37
+ * </Pagination>
38
+ * ```
39
+ */
40
+ import {
41
+ createContext,
42
+ forwardRef,
43
+ useCallback,
44
+ useContext,
45
+ useMemo,
46
+ useState,
47
+ type ReactNode,
48
+ } from 'react';
49
+ import { View, type ViewProps } from 'react-native';
50
+ import { tv, type VariantProps } from 'tailwind-variants';
51
+ import { useCSSVariable } from 'uniwind';
52
+ import { ChevronLeftIcon, ChevronRightIcon, EllipsisIcon } from '../../icons';
53
+ import { useDirection } from '../../hooks/use-direction';
54
+ import {
55
+ AnimatedPressable,
56
+ type AnimatedPressableProps,
57
+ } from '../../primitives/animated-pressable';
58
+ import { Text, type TextProps, textChildren } from '../../primitives/text';
59
+ import { cn } from '../../utils/cn';
60
+ import { selectionTick } from '../../utils/haptics';
61
+
62
+ export type PaginationVariant = 'numbers' | 'compact' | 'simple';
63
+ export type PaginationSize = 'sm' | 'default';
64
+
65
+ /**
66
+ * What the small size gives back as padding. 44pt is the smallest target a
67
+ * finger reliably hits, and the row is allowed to get tighter than that only
68
+ * because the reach is restored outside the paint.
69
+ */
70
+ const SMALL_HIT_SLOP = { top: 4, bottom: 4, left: 4, right: 4 } as const;
71
+
72
+ const paginationVariants = tv({
73
+ slots: {
74
+ root: 'flex-row items-center',
75
+ list: 'flex-row items-center',
76
+ item: 'items-center justify-center rounded-lg',
77
+ itemLabel: 'font-medium tabular-nums',
78
+ ellipsis: 'items-center justify-center',
79
+ status: 'text-muted-foreground tabular-nums',
80
+ summary: 'text-muted-foreground tabular-nums',
81
+ },
82
+ variants: {
83
+ size: {
84
+ default: {
85
+ list: 'gap-1',
86
+ item: 'h-11 min-w-11 px-1',
87
+ itemLabel: 'text-sm',
88
+ ellipsis: 'h-11 w-11',
89
+ status: 'text-sm',
90
+ summary: 'text-sm',
91
+ },
92
+ sm: {
93
+ list: 'gap-0.5',
94
+ item: 'h-9 min-w-9 px-1',
95
+ itemLabel: 'text-xs',
96
+ ellipsis: 'h-9 w-9',
97
+ status: 'text-xs',
98
+ summary: 'text-xs',
99
+ },
100
+ },
101
+ /** Whether the item is the page you are on. */
102
+ current: {
103
+ true: { item: 'bg-primary', itemLabel: 'text-primary-foreground' },
104
+ false: { itemLabel: 'text-foreground' },
105
+ },
106
+ disabled: {
107
+ true: { item: 'opacity-[0.4]' },
108
+ },
109
+ },
110
+ defaultVariants: {
111
+ size: 'default',
112
+ current: false,
113
+ },
114
+ });
115
+
116
+ const iconSize: Record<PaginationSize, number> = { default: 20, sm: 16 };
117
+
118
+ /**
119
+ * The state every part reads. A `Pagination.Previous` dropped into a row of
120
+ * your own still knows which page it is on and whether it is at the start,
121
+ * because that answer lives here rather than in the props it was given.
122
+ */
123
+ interface PaginationContextValue {
124
+ page: number;
125
+ count: number;
126
+ size: PaginationSize;
127
+ disabled: boolean;
128
+ goTo: (page: number) => void;
129
+ }
130
+
131
+ const PaginationContext = createContext<PaginationContextValue | null>(null);
132
+
133
+ function usePagination(part: string): PaginationContextValue {
134
+ const context = useContext(PaginationContext);
135
+ if (!context) {
136
+ throw new Error(`${part} must be used inside <Pagination>.`);
137
+ }
138
+ return context;
139
+ }
140
+
141
+ const range = (start: number, end: number): number[] =>
142
+ end < start ? [] : Array.from({ length: end - start + 1 }, (_, i) => start + i);
143
+
144
+ export type PaginationItemValue = number | 'start-ellipsis' | 'end-ellipsis';
145
+
146
+ /**
147
+ * Which numbers to draw, and where the gaps go.
148
+ *
149
+ * The run is a fixed width rather than a sliding window: the same number of
150
+ * targets whichever page you are on, so the row does not reflow under the
151
+ * finger as you step through it and the arrow you were aiming at stays put.
152
+ * `boundaries` pins the ends, `siblings` pads the middle, and a gap of exactly
153
+ * one page is drawn as that page instead of an ellipsis — a `…` that hides a
154
+ * single number is longer than the number.
155
+ *
156
+ * Exported because a caller sometimes needs the same run for a control of their
157
+ * own, and two implementations of this would drift.
158
+ */
159
+ export function paginationRange({
160
+ count,
161
+ page,
162
+ siblings = 1,
163
+ boundaries = 1,
164
+ }: {
165
+ count: number;
166
+ page: number;
167
+ siblings?: number;
168
+ boundaries?: number;
169
+ }): PaginationItemValue[] {
170
+ const startPages = range(1, Math.min(boundaries, count));
171
+ const endPages = range(Math.max(count - boundaries + 1, boundaries + 1), count);
172
+
173
+ const siblingsStart = Math.max(
174
+ Math.min(page - siblings, count - boundaries - siblings * 2 - 1),
175
+ boundaries + 2
176
+ );
177
+ const siblingsEnd = Math.min(
178
+ Math.max(page + siblings, boundaries + siblings * 2 + 2),
179
+ endPages.length > 0 ? (endPages[0] as number) - 2 : count - 1
180
+ );
181
+
182
+ return [
183
+ ...startPages,
184
+ ...(siblingsStart > boundaries + 2
185
+ ? (['start-ellipsis'] as PaginationItemValue[])
186
+ : boundaries + 1 < count - boundaries
187
+ ? [boundaries + 1]
188
+ : []),
189
+ ...range(siblingsStart, siblingsEnd),
190
+ ...(siblingsEnd < count - boundaries - 1
191
+ ? (['end-ellipsis'] as PaginationItemValue[])
192
+ : count - boundaries > boundaries
193
+ ? [count - boundaries]
194
+ : []),
195
+ ...endPages,
196
+ ];
197
+ }
198
+
199
+ export interface PaginationProps
200
+ extends Omit<ViewProps, 'children'>,
201
+ VariantProps<typeof paginationVariants> {
202
+ className?: string;
203
+ /**
204
+ * How many pages there are. Pages are numbered from 1, so this is also the
205
+ * last page's number.
206
+ */
207
+ count: number;
208
+ /** The page being shown. Pass it to control the component. */
209
+ page?: number;
210
+ /** The page to start on when the component keeps its own. Defaults to 1. */
211
+ defaultPage?: number;
212
+ /** Called with the page that was asked for, already clamped to `count`. */
213
+ onPageChange?: (page: number) => void;
214
+ /** Which presentation to draw. */
215
+ variant?: PaginationVariant;
216
+ size?: PaginationSize;
217
+ /**
218
+ * How many pages to keep either side of the current one. Raise it on a
219
+ * tablet, where there is room for a longer run.
220
+ */
221
+ siblings?: number;
222
+ /** How many pages to keep pinned at each end of the run. */
223
+ boundaries?: number;
224
+ /**
225
+ * Show the previous and next arrows. Turning them off leaves the numbers
226
+ * alone, so only do it where something else moves the page — a swipe, a
227
+ * scroller reaching its end.
228
+ */
229
+ controls?: boolean;
230
+ /** How far tapping an ellipsis jumps. */
231
+ pageJump?: number;
232
+ /** Greys out and deafens the whole row — for a page that is still loading. */
233
+ disabled?: boolean;
234
+ /** Labels the row for a screen reader. Defaults to "Pagination". */
235
+ accessibilityLabel?: string;
236
+ /** Leading content — a `Pagination.Status`, a page-size control of your own. */
237
+ children?: ReactNode;
238
+ }
239
+
240
+ const PaginationRoot = forwardRef<View, PaginationProps>(
241
+ (
242
+ {
243
+ className,
244
+ count,
245
+ page: controlledPage,
246
+ defaultPage = 1,
247
+ onPageChange,
248
+ variant = 'numbers',
249
+ size = 'default',
250
+ siblings = 1,
251
+ boundaries = 1,
252
+ controls = true,
253
+ pageJump = 5,
254
+ disabled = false,
255
+ accessibilityLabel = 'Pagination',
256
+ children,
257
+ ...props
258
+ },
259
+ ref
260
+ ) => {
261
+ const [internalPage, setInternalPage] = useState(defaultPage);
262
+ const isControlled = controlledPage !== undefined;
263
+ // Clamped on the way out as well as on the way in: `count` can shrink under
264
+ // a controlled page — a filter narrowing the result set — and a page number
265
+ // past the end would light nothing and disable both arrows at once.
266
+ const page = Math.min(Math.max(isControlled ? controlledPage : internalPage, 1), count);
267
+
268
+ const goTo = useCallback(
269
+ (next: number) => {
270
+ const clamped = Math.min(Math.max(next, 1), count);
271
+ if (clamped === page || disabled) return;
272
+ // On the frame of the press rather than after the fetch: the tick is
273
+ // what ties the feeling to the tap, and the rows arrive whenever they
274
+ // arrive.
275
+ selectionTick();
276
+ if (!isControlled) setInternalPage(clamped);
277
+ onPageChange?.(clamped);
278
+ },
279
+ [count, page, disabled, isControlled, onPageChange]
280
+ );
281
+
282
+ const context = useMemo<PaginationContextValue>(
283
+ () => ({ page, count, size, disabled, goTo }),
284
+ [page, count, size, disabled, goTo]
285
+ );
286
+
287
+ const items = useMemo(
288
+ () =>
289
+ variant === 'numbers'
290
+ ? paginationRange({ count, page, siblings, boundaries })
291
+ : [],
292
+ [variant, count, page, siblings, boundaries]
293
+ );
294
+
295
+ const { root, list } = paginationVariants({ size });
296
+
297
+ return (
298
+ <PaginationContext.Provider value={context}>
299
+ <View
300
+ ref={ref}
301
+ role="navigation"
302
+ accessibilityLabel={accessibilityLabel}
303
+ className={root({
304
+ // With nothing on the leading edge the controls are the whole row
305
+ // and centre in it; with something there they part to the two ends,
306
+ // which is the shape a table footer wants.
307
+ className: cn(children ? 'justify-between gap-3' : 'justify-center', className),
308
+ })}
309
+ {...props}
310
+ >
311
+ {textChildren(children, (text) => (
312
+ <PaginationStatus>{text}</PaginationStatus>
313
+ ))}
314
+
315
+ <View className={list()}>
316
+ {controls ? <PaginationPrevious label={variant === 'simple'} /> : null}
317
+
318
+ {variant === 'numbers'
319
+ ? items.map((item, index) =>
320
+ typeof item === 'number' ? (
321
+ <PaginationItem key={item} page={item} />
322
+ ) : (
323
+ <PaginationEllipsis
324
+ key={`${item}-${index}`}
325
+ direction={item === 'start-ellipsis' ? -1 : 1}
326
+ jump={pageJump}
327
+ />
328
+ )
329
+ )
330
+ : null}
331
+
332
+ {variant === 'compact' ? <PaginationSummary /> : null}
333
+
334
+ {controls ? <PaginationNext label={variant === 'simple'} /> : null}
335
+ </View>
336
+ </View>
337
+ </PaginationContext.Provider>
338
+ );
339
+ }
340
+ );
341
+ PaginationRoot.displayName = 'Pagination';
342
+
343
+ export interface PaginationItemProps
344
+ extends Omit<AnimatedPressableProps, 'children' | 'disabled'> {
345
+ className?: string;
346
+ /** The page this target goes to. */
347
+ page: number;
348
+ /** Styles the number. */
349
+ labelClassName?: string;
350
+ children?: ReactNode;
351
+ }
352
+
353
+ /**
354
+ * One numbered target. Announced as a selected button when it is the page you
355
+ * are on, so the current page is spoken as state rather than only painted.
356
+ */
357
+ const PaginationItem = forwardRef<View, PaginationItemProps>(
358
+ ({ className, labelClassName, page, children, ...props }, ref) => {
359
+ const { page: current, size, disabled, goTo } = usePagination('Pagination.Item');
360
+ const isCurrent = page === current;
361
+ const { item, itemLabel } = paginationVariants({ size, current: isCurrent, disabled });
362
+
363
+ return (
364
+ <AnimatedPressable
365
+ ref={ref}
366
+ accessibilityRole="button"
367
+ accessibilityLabel={`Page ${page}`}
368
+ accessibilityState={{ selected: isCurrent, disabled }}
369
+ disabled={disabled}
370
+ hitSlop={size === 'sm' ? SMALL_HIT_SLOP : undefined}
371
+ onPress={() => goTo(page)}
372
+ className={item({ className })}
373
+ {...props}
374
+ >
375
+ {textChildren(children ?? String(page), (text) => (
376
+ <Text className={itemLabel({ className: labelClassName })}>{text}</Text>
377
+ ))}
378
+ </AnimatedPressable>
379
+ );
380
+ }
381
+ );
382
+ PaginationItem.displayName = 'Pagination.Item';
383
+
384
+ export interface PaginationPreviousProps extends Omit<AnimatedPressableProps, 'children'> {
385
+ className?: string;
386
+ /** Write the word beside the arrow, rather than leaving it as a glyph. */
387
+ label?: boolean;
388
+ }
389
+
390
+ export interface PaginationNextProps extends Omit<AnimatedPressableProps, 'children'> {
391
+ className?: string;
392
+ /** Write the word beside the arrow, rather than leaving it as a glyph. */
393
+ label?: boolean;
394
+ }
395
+
396
+ type PaginationArrowProps = PaginationPreviousProps;
397
+
398
+ /**
399
+ * The two arrows, which are the same button pointed the other way.
400
+ *
401
+ * Which glyph is "back" is a question about the reading direction, not about
402
+ * the component: in a right-to-left layout the previous page is to the right.
403
+ * Yoga mirrors the row on its own; the arrowhead inside it has to be chosen.
404
+ */
405
+ function useArrow(step: -1 | 1, part: string) {
406
+ const { page, count, size, disabled, goTo } = usePagination(part);
407
+ const rtl = useDirection() === 'rtl';
408
+ const color = useCSSVariable('--color-foreground');
409
+ const target = page + step;
410
+ const spent = disabled || target < 1 || target > count;
411
+ const Glyph = (step === -1) === rtl ? ChevronRightIcon : ChevronLeftIcon;
412
+
413
+ return {
414
+ Glyph,
415
+ size,
416
+ spent,
417
+ color: typeof color === 'string' ? color : '#0a0a0a',
418
+ press: () => goTo(target),
419
+ };
420
+ }
421
+
422
+ function PaginationArrow({
423
+ step,
424
+ part,
425
+ word,
426
+ className,
427
+ label,
428
+ ...props
429
+ }: PaginationArrowProps & { step: -1 | 1; part: string; word: string }) {
430
+ const { Glyph, size, spent, color, press } = useArrow(step, part);
431
+ const { item, itemLabel } = paginationVariants({ size, disabled: spent });
432
+
433
+ return (
434
+ <AnimatedPressable
435
+ accessibilityRole="button"
436
+ accessibilityLabel={word}
437
+ accessibilityState={{ disabled: spent }}
438
+ disabled={spent}
439
+ hitSlop={size === 'sm' ? SMALL_HIT_SLOP : undefined}
440
+ onPress={press}
441
+ className={item({
442
+ className: cn('flex-row gap-1', label && (size === 'sm' ? 'px-2' : 'px-3'), className),
443
+ })}
444
+ {...props}
445
+ >
446
+ {step === -1 ? <Glyph size={iconSize[size]} color={color} /> : null}
447
+ {label ? <Text className={itemLabel()}>{word}</Text> : null}
448
+ {step === 1 ? <Glyph size={iconSize[size]} color={color} /> : null}
449
+ </AnimatedPressable>
450
+ );
451
+ }
452
+
453
+ /** Back one page. Dead on the first page rather than looping to the last. */
454
+ const PaginationPrevious = ({ ...props }: PaginationPreviousProps) => (
455
+ <PaginationArrow step={-1} part="Pagination.Previous" word="Previous" {...props} />
456
+ );
457
+ PaginationPrevious.displayName = 'Pagination.Previous';
458
+
459
+ /** Forward one page. Dead on the last page rather than looping to the first. */
460
+ const PaginationNext = ({ ...props }: PaginationNextProps) => (
461
+ <PaginationArrow step={1} part="Pagination.Next" word="Next" {...props} />
462
+ );
463
+ PaginationNext.displayName = 'Pagination.Next';
464
+
465
+ export interface PaginationEllipsisProps
466
+ extends Omit<AnimatedPressableProps, 'children'> {
467
+ className?: string;
468
+ /** Which way the gap runs: `-1` towards page 1, `1` towards the last page. */
469
+ direction?: -1 | 1;
470
+ /** How many pages a tap covers. */
471
+ jump?: number;
472
+ }
473
+
474
+ /**
475
+ * The gap in the run, and a way across it. Tapping jumps `jump` pages towards
476
+ * the end the gap is on — which is why the two ellipses are told apart: a jump
477
+ * that always went forwards would strand anyone reading the row right to left.
478
+ */
479
+ const PaginationEllipsis = forwardRef<View, PaginationEllipsisProps>(
480
+ ({ className, direction = 1, jump = 5, ...props }, ref) => {
481
+ const { page, size, disabled, goTo } = usePagination('Pagination.Ellipsis');
482
+ const color = useCSSVariable('--color-muted-foreground');
483
+ const { ellipsis } = paginationVariants({ size });
484
+
485
+ return (
486
+ <AnimatedPressable
487
+ ref={ref}
488
+ accessibilityRole="button"
489
+ accessibilityLabel={
490
+ direction === -1 ? `Back ${jump} pages` : `Forward ${jump} pages`
491
+ }
492
+ accessibilityState={{ disabled }}
493
+ disabled={disabled}
494
+ hitSlop={size === 'sm' ? SMALL_HIT_SLOP : undefined}
495
+ onPress={() => goTo(page + direction * jump)}
496
+ className={ellipsis({ className })}
497
+ {...props}
498
+ >
499
+ <EllipsisIcon
500
+ size={iconSize[size]}
501
+ color={typeof color === 'string' ? color : '#737373'}
502
+ />
503
+ </AnimatedPressable>
504
+ );
505
+ }
506
+ );
507
+ PaginationEllipsis.displayName = 'Pagination.Ellipsis';
508
+
509
+ export interface PaginationSummaryProps extends TextProps {
510
+ className?: string;
511
+ }
512
+
513
+ /**
514
+ * `3 / 12` — where you are, in the space two arrows leave between them.
515
+ *
516
+ * Digits and a slash rather than "Page 3 of 12", because a phone has no room
517
+ * for the sentence and the sentence would need translating. `Pagination.Status`
518
+ * is the place for words.
519
+ */
520
+ const PaginationSummary = forwardRef<React.ElementRef<typeof Text>, PaginationSummaryProps>(
521
+ ({ className, ...props }, ref) => {
522
+ const { page, count, size } = usePagination('Pagination.Summary');
523
+ const { summary } = paginationVariants({ size });
524
+
525
+ return (
526
+ <Text
527
+ ref={ref}
528
+ accessibilityLabel={`Page ${page} of ${count}`}
529
+ className={summary({ className: cn('px-2', className) })}
530
+ {...props}
531
+ >
532
+ {page} / {count}
533
+ </Text>
534
+ );
535
+ }
536
+ );
537
+ PaginationSummary.displayName = 'Pagination.Summary';
538
+
539
+ export interface PaginationStatusProps extends TextProps {
540
+ className?: string;
541
+ /** Which page the span is counted from. Read from the root when left out. */
542
+ page?: number;
543
+ /** How many rows a page holds. Required for the span to be worked out. */
544
+ pageSize?: number;
545
+ /** How many rows there are altogether. */
546
+ total?: number;
547
+ children?: ReactNode;
548
+ }
549
+
550
+ /**
551
+ * The line that says how much of the set you are looking at — `1–20 of 240`.
552
+ *
553
+ * Worth having beside a table because a page number alone does not answer the
554
+ * question people actually have, which is how much is left. Given `children`
555
+ * it renders those instead, for a set whose size is not known yet.
556
+ */
557
+ const PaginationStatus = forwardRef<React.ElementRef<typeof Text>, PaginationStatusProps>(
558
+ ({ className, page: ownPage, pageSize, total, children, ...props }, ref) => {
559
+ const context = useContext(PaginationContext);
560
+ const size = context?.size ?? 'default';
561
+ const page = ownPage ?? context?.page ?? 1;
562
+ const { status } = paginationVariants({ size });
563
+
564
+ const span = useMemo(() => {
565
+ if (children !== undefined) return null;
566
+ if (pageSize === undefined || total === undefined) return null;
567
+ if (total <= 0) return 'None';
568
+ const from = (page - 1) * pageSize + 1;
569
+ // The last page is nearly always short; counting a full one past the end
570
+ // is the classic off-by-a-page-size.
571
+ const to = Math.min(page * pageSize, total);
572
+ return `${from}–${to} of ${total}`;
573
+ }, [children, page, pageSize, total]);
574
+
575
+ return (
576
+ <Text ref={ref} className={status({ className })} {...props}>
577
+ {span ?? textChildren(children)}
578
+ </Text>
579
+ );
580
+ }
581
+ );
582
+ PaginationStatus.displayName = 'Pagination.Status';
583
+
584
+ export const Pagination = Object.assign(PaginationRoot, {
585
+ Item: PaginationItem,
586
+ Previous: PaginationPrevious,
587
+ Next: PaginationNext,
588
+ Ellipsis: PaginationEllipsis,
589
+ Summary: PaginationSummary,
590
+ Status: PaginationStatus,
591
+ });