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