@rozie-ui/pagination-react 0.0.0-stage → 0.1.9
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/CHANGELOG.md +62 -0
- package/LICENSE +21 -0
- package/README.md +111 -2
- package/dist/Pagination.css +59 -0
- package/dist/index.cjs +311 -0
- package/dist/index.d.cts +75 -0
- package/dist/index.d.mts +78 -0
- package/dist/index.mjs +306 -0
- package/package.json +71 -3
- package/src/Pagination.css +59 -0
- package/src/Pagination.d.ts +63 -0
- package/src/Pagination.tsx +279 -0
- package/src/index.ts +6 -0
- package/src/internal/paginationItems.ts +137 -0
- package/src/themes/base.css +74 -0
- package/src/themes/bootstrap.css +46 -0
- package/src/themes/material.css +44 -0
- package/src/themes/shadcn.css +48 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { Fragment, forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { flushSync } from 'react-dom';
|
|
4
|
+
import { clsx, mergeListeners, pickListeners, rozieAttr, rozieDisplay, useControllableState } from '@rozie/runtime-react';
|
|
5
|
+
import './Pagination.css';
|
|
6
|
+
import { paginationItems } from './internal/paginationItems';
|
|
7
|
+
|
|
8
|
+
// ---- derived view (ONE plain function, uniform x6) ---------------------
|
|
9
|
+
// The whole render model in a single call: { totalPages, page, pages,
|
|
10
|
+
// hasPrev, hasNext }. A PLAIN function (not $computed) so it reads uniformly
|
|
11
|
+
// on all six targets and can be aliased in handlers without the Solid
|
|
12
|
+
// accessor divergence. Returns a FRESH object each call — never feed it to a
|
|
13
|
+
// reference-equality $watch getter.
|
|
14
|
+
|
|
15
|
+
/** The `change` event payload: the page that was just navigated to. */
|
|
16
|
+
export interface PaginationChangePayload {
|
|
17
|
+
/** The new, clamped, 1-based current page. */
|
|
18
|
+
page: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface PrevControlCtx { disabled: boolean; goto: () => void; page: number; }
|
|
22
|
+
|
|
23
|
+
interface EllipsisCtx { index: number; }
|
|
24
|
+
|
|
25
|
+
interface ItemCtx { page: number; selected: boolean; goto: () => void; }
|
|
26
|
+
|
|
27
|
+
interface NextControlCtx { disabled: boolean; goto: () => void; page: number; }
|
|
28
|
+
|
|
29
|
+
interface PaginationProps extends Omit<import('react').ComponentPropsWithoutRef<'nav'>, 'modelValue' | 'defaultModelValue' | 'onModelValueChange' | 'totalPages' | 'total' | 'pageSize' | 'siblingCount' | 'boundaryCount' | 'disabled' | 'ariaLabel' | 'onChange' | 'renderPrevControl' | 'renderEllipsis' | 'renderItem' | 'renderNextControl' | 'slots' | 'children' | 'dangerouslySetInnerHTML'> {
|
|
30
|
+
/**
|
|
31
|
+
* The 1-based current page (two-way model). Clamped into `[1, totalPages]`. Bind it with `r-model:modelValue` / `v-model:modelValue` / `modelValue` + `onModelValueChange`; it is also the Angular ControlValueAccessor control value.
|
|
32
|
+
*/
|
|
33
|
+
modelValue?: number;
|
|
34
|
+
defaultModelValue?: number;
|
|
35
|
+
onModelValueChange?: (modelValue: number) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Explicit total page count. When provided (> 0) it takes precedence over `total` + `pageSize`. Use it when the backend already reports the page count.
|
|
38
|
+
*/
|
|
39
|
+
totalPages?: (number) | null;
|
|
40
|
+
/**
|
|
41
|
+
* Total item count. Combined with `pageSize` to derive the page count (`ceil(total / pageSize)`) when `totalPages` is not given.
|
|
42
|
+
*/
|
|
43
|
+
total?: (number) | null;
|
|
44
|
+
/**
|
|
45
|
+
* Items per page. Combined with `total` to derive the page count when `totalPages` is not given.
|
|
46
|
+
*/
|
|
47
|
+
pageSize?: (number) | null;
|
|
48
|
+
/**
|
|
49
|
+
* Number of page buttons shown on each side of the current page (the sibling window). Larger values show more context around the current page.
|
|
50
|
+
*/
|
|
51
|
+
siblingCount?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Number of page buttons always shown at each boundary (the first and last `boundaryCount` pages), regardless of the current page.
|
|
54
|
+
*/
|
|
55
|
+
boundaryCount?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Disable the entire control — every page button and the prev/next controls become non-interactive and are marked `aria-disabled`.
|
|
58
|
+
*/
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Accessible name for the surrounding `<nav>` landmark (its `aria-label`). Defaults to `"Pagination"`.
|
|
62
|
+
*/
|
|
63
|
+
ariaLabel?: string;
|
|
64
|
+
onChange?: (payload: PaginationChangePayload) => void;
|
|
65
|
+
renderPrevControl?: (ctx: PrevControlCtx) => ReactNode;
|
|
66
|
+
renderEllipsis?: (ctx: EllipsisCtx) => ReactNode;
|
|
67
|
+
renderItem?: (ctx: ItemCtx) => ReactNode;
|
|
68
|
+
renderNextControl?: (ctx: NextControlCtx) => ReactNode;
|
|
69
|
+
slots?: Record<string, () => import('react').ReactNode>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PaginationHandle {
|
|
73
|
+
goto: (page: number) => void;
|
|
74
|
+
next: () => void;
|
|
75
|
+
prev: () => void;
|
|
76
|
+
first: () => void;
|
|
77
|
+
last: () => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const Pagination = forwardRef<PaginationHandle, PaginationProps>(function Pagination(_props: PaginationProps, ref): JSX.Element {
|
|
81
|
+
const props: Omit<PaginationProps, 'totalPages' | 'total' | 'pageSize' | 'siblingCount' | 'boundaryCount' | 'disabled' | 'ariaLabel'> & { totalPages: (number) | null; total: (number) | null; pageSize: (number) | null; siblingCount: number; boundaryCount: number; disabled: boolean; ariaLabel: string } = {
|
|
82
|
+
..._props,
|
|
83
|
+
totalPages: _props.totalPages ?? null,
|
|
84
|
+
total: _props.total ?? null,
|
|
85
|
+
pageSize: _props.pageSize ?? null,
|
|
86
|
+
siblingCount: _props.siblingCount ?? 1,
|
|
87
|
+
boundaryCount: _props.boundaryCount ?? 1,
|
|
88
|
+
disabled: _props.disabled ?? false,
|
|
89
|
+
ariaLabel: _props.ariaLabel ?? 'Pagination',
|
|
90
|
+
};
|
|
91
|
+
const attrs: Record<string, unknown> = (() => {
|
|
92
|
+
const { modelValue, totalPages, total, pageSize, siblingCount, boundaryCount, disabled, ariaLabel, defaultValue, onModelValueChange, defaultModelValue, onChange, renderPrevControl, renderEllipsis, renderItem, renderNextControl, slots, ...rest } = _props as PaginationProps & Record<string, unknown>;
|
|
93
|
+
void modelValue; void totalPages; void total; void pageSize; void siblingCount; void boundaryCount; void disabled; void ariaLabel; void defaultValue; void onModelValueChange; void defaultModelValue; void onChange; void renderPrevControl; void renderEllipsis; void renderItem; void renderNextControl; void slots;
|
|
94
|
+
return rest;
|
|
95
|
+
})();
|
|
96
|
+
const [modelValue, setModelValue] = useControllableState({
|
|
97
|
+
value: props.modelValue,
|
|
98
|
+
defaultValue: props.defaultModelValue ?? 1,
|
|
99
|
+
onValueChange: props.onModelValueChange,
|
|
100
|
+
});
|
|
101
|
+
const nav = useRef<HTMLElement | null>(null);
|
|
102
|
+
|
|
103
|
+
// ---- derived view (ONE plain function, uniform x6) ---------------------
|
|
104
|
+
// The whole render model in a single call: { totalPages, page, pages,
|
|
105
|
+
// hasPrev, hasNext }. A PLAIN function (not $computed) so it reads uniformly
|
|
106
|
+
// on all six targets and can be aliased in handlers without the Solid
|
|
107
|
+
// accessor divergence. Returns a FRESH object each call — never feed it to a
|
|
108
|
+
// reference-equality $watch getter.
|
|
109
|
+
function model() {
|
|
110
|
+
return paginationItems({
|
|
111
|
+
page: modelValue,
|
|
112
|
+
totalPages: props.totalPages,
|
|
113
|
+
total: props.total,
|
|
114
|
+
pageSize: props.pageSize,
|
|
115
|
+
siblingCount: props.siblingCount,
|
|
116
|
+
boundaryCount: props.boundaryCount
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The resolved effective total page count (read in the template + handlers).
|
|
121
|
+
// NAMED `effectivePages`, NOT `totalPages` — a `totalPages` helper would shadow
|
|
122
|
+
// the `totalPages` PROP, which on Lit becomes a class field of type `number`
|
|
123
|
+
// (hard TS2300/TS2717 against a `() => number` helper). The prop-name-collision
|
|
124
|
+
// sibling of the otp `inputMode` gotcha.
|
|
125
|
+
function effectivePages() {
|
|
126
|
+
return model().totalPages;
|
|
127
|
+
}
|
|
128
|
+
// The clamped current page (the raw prop may be out of range).
|
|
129
|
+
function currentPage() {
|
|
130
|
+
return model().page;
|
|
131
|
+
}
|
|
132
|
+
function canPrev() {
|
|
133
|
+
return model().hasPrev;
|
|
134
|
+
}
|
|
135
|
+
function canNext() {
|
|
136
|
+
return model().hasNext;
|
|
137
|
+
}
|
|
138
|
+
function isActive(page: any) {
|
|
139
|
+
return page === currentPage();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Roving-tabindex value for a control: the active page is a tab stop (0), the
|
|
143
|
+
// other pages are -1 (arrow keys reach them). The prev/next controls always pass
|
|
144
|
+
// `true`, so they stay tab stops of their own. The return type is annotated `number | undefined` ON
|
|
145
|
+
// PURPOSE: the React emitter wraps every numeric `:attr` binding in
|
|
146
|
+
// `(expr) ?? undefined`, and a PROVABLY non-null value (a bare `0`/`-1` or a
|
|
147
|
+
// `0 : -1` ternary) trips TS2869 "right operand of ?? is unreachable". Routing
|
|
148
|
+
// every tabindex through this nullable-typed helper keeps the `?? undefined`
|
|
149
|
+
// reachable (the data-table cellTabindex precedent).
|
|
150
|
+
function tabIndexFor(active: any): number | undefined {
|
|
151
|
+
return active ? 0 : -1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- write funnel (single $emit site) ----------------------------------
|
|
155
|
+
// Clamp to [1, totalPages], write the model, and emit `change` with the new
|
|
156
|
+
// page. NOT named `setModelValue` (that collides with React's generated model
|
|
157
|
+
// setter → ROZ524) — `goToPage` is collision-safe across all six leaves.
|
|
158
|
+
const { onChange: _rozieProp_onChange } = props;
|
|
159
|
+
const goToPage = useCallback((page: any) => {
|
|
160
|
+
if (props.disabled) return;
|
|
161
|
+
const tp = effectivePages();
|
|
162
|
+
let target = typeof page === 'number' ? Math.floor(page) : 1;
|
|
163
|
+
if (target < 1) target = 1;
|
|
164
|
+
if (target > tp) target = tp;
|
|
165
|
+
if (target === currentPage()) return;
|
|
166
|
+
setModelValue(target);
|
|
167
|
+
_rozieProp_onChange && _rozieProp_onChange({
|
|
168
|
+
page: target
|
|
169
|
+
});
|
|
170
|
+
}, [_rozieProp_onChange, currentPage, effectivePages, props.disabled, setModelValue]);
|
|
171
|
+
const goNext = useCallback(() => {
|
|
172
|
+
if (canNext()) goToPage(currentPage() + 1);
|
|
173
|
+
}, [canNext, currentPage, goToPage]);
|
|
174
|
+
const goPrev = useCallback(() => {
|
|
175
|
+
if (canPrev()) goToPage(currentPage() - 1);
|
|
176
|
+
}, [canPrev, currentPage, goToPage]);
|
|
177
|
+
function goFirst() {
|
|
178
|
+
return goToPage(1);
|
|
179
|
+
}
|
|
180
|
+
function goLast() {
|
|
181
|
+
return goToPage(effectivePages());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- roving focus across the page controls -----------------------------
|
|
185
|
+
// Read $refs.nav only here / in handlers (post-mount → ROZ123-safe).
|
|
186
|
+
// querySelectorAll<HTMLElement> reaches the controls inside Lit's shadow root
|
|
187
|
+
// too; the generic gives `.focus()` (Element has no `.focus`, TS2339).
|
|
188
|
+
function controls() {
|
|
189
|
+
const nav$local = nav.current;
|
|
190
|
+
if (!nav$local) return [];
|
|
191
|
+
return Array.from(nav$local.querySelectorAll('[data-page-control]')) as HTMLElement[];
|
|
192
|
+
}
|
|
193
|
+
function focusControlAt(idx: any) {
|
|
194
|
+
const els = controls();
|
|
195
|
+
if (els.length === 0) return;
|
|
196
|
+
let i = idx;
|
|
197
|
+
if (i < 0) i = 0;
|
|
198
|
+
if (i >= els.length) i = els.length - 1;
|
|
199
|
+
const el = els[i];
|
|
200
|
+
if (el && el.focus) el.focus();
|
|
201
|
+
}
|
|
202
|
+
function focusedIndex() {
|
|
203
|
+
const els = controls();
|
|
204
|
+
const nav$local = nav.current;
|
|
205
|
+
// The focused element within the nav's OWN root (the document, or the shadow
|
|
206
|
+
// root on Lit): `ownerDocument.activeElement` stops at the shadow host there and
|
|
207
|
+
// would never match a control.
|
|
208
|
+
const root = nav$local ? nav$local.getRootNode() as Document | ShadowRoot : null;
|
|
209
|
+
const active = root ? root.activeElement : null;
|
|
210
|
+
return els.indexOf(active as HTMLElement);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Roving keyboard navigation: arrows move focus between controls, Home/End jump
|
|
214
|
+
// to the ends. Each page control keeps its tabindex via the template (the active
|
|
215
|
+
// page is 0, the other pages -1), so only the active page is a tab stop among the
|
|
216
|
+
// page numbers.
|
|
217
|
+
const onControlKeydown = useCallback(($event: any) => {
|
|
218
|
+
if (props.disabled) return;
|
|
219
|
+
const key = $event.key;
|
|
220
|
+
const cur = focusedIndex();
|
|
221
|
+
if (key === 'ArrowRight' || key === 'ArrowDown') {
|
|
222
|
+
$event.preventDefault();
|
|
223
|
+
focusControlAt(cur + 1);
|
|
224
|
+
} else if (key === 'ArrowLeft' || key === 'ArrowUp') {
|
|
225
|
+
$event.preventDefault();
|
|
226
|
+
focusControlAt(cur - 1);
|
|
227
|
+
} else if (key === 'Home') {
|
|
228
|
+
$event.preventDefault();
|
|
229
|
+
focusControlAt(0);
|
|
230
|
+
} else if (key === 'End') {
|
|
231
|
+
$event.preventDefault();
|
|
232
|
+
focusControlAt(controls().length - 1);
|
|
233
|
+
}
|
|
234
|
+
}, [controls, focusControlAt, focusedIndex, props.disabled]);
|
|
235
|
+
// ---- imperative handle -------------------------------------------------
|
|
236
|
+
// Consumer-callable verbs. `goto` clamps; `next`/`prev`/`first`/`last` are the
|
|
237
|
+
// bounds-aware steppers. None collide with an emit name (`change`) or the React
|
|
238
|
+
// model setter (`setModelValue`).
|
|
239
|
+
function goto(page: any) {
|
|
240
|
+
return goToPage(page);
|
|
241
|
+
}
|
|
242
|
+
function next() {
|
|
243
|
+
return goNext();
|
|
244
|
+
}
|
|
245
|
+
function prev() {
|
|
246
|
+
return goPrev();
|
|
247
|
+
}
|
|
248
|
+
function first() {
|
|
249
|
+
return goFirst();
|
|
250
|
+
}
|
|
251
|
+
function last() {
|
|
252
|
+
return goLast();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const _rozieExposeRef = useRef({ goto, next, prev, first, last });
|
|
256
|
+
_rozieExposeRef.current = { goto, next, prev, first, last };
|
|
257
|
+
useImperativeHandle(ref, () => ({ goto: (...args: Parameters<typeof goto>): ReturnType<typeof goto> => flushSync(() => _rozieExposeRef.current.goto(...args)), next: (...args: Parameters<typeof next>): ReturnType<typeof next> => flushSync(() => _rozieExposeRef.current.next(...args)), prev: (...args: Parameters<typeof prev>): ReturnType<typeof prev> => flushSync(() => _rozieExposeRef.current.prev(...args)), first: (...args: Parameters<typeof first>): ReturnType<typeof first> => flushSync(() => _rozieExposeRef.current.first(...args)), last: (...args: Parameters<typeof last>): ReturnType<typeof last> => flushSync(() => _rozieExposeRef.current.last(...args)) }), []);
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<>
|
|
261
|
+
<nav ref={nav} aria-label={props.ariaLabel} {...attrs} className={clsx(clsx("rozie-pagination", { "rozie-pagination--disabled": props.disabled }), (attrs.className as string | undefined))} {...mergeListeners({ onKeyDown: ($event) => { onControlKeydown($event); } } satisfies import('react').ComponentPropsWithoutRef<'nav'> & Record<string, unknown>, pickListeners(attrs))} data-rozie-s-de247ae2="">
|
|
262
|
+
|
|
263
|
+
{(props.renderPrevControl ?? props.slots?.['prevControl']) ? ((props.renderPrevControl ?? props.slots?.['prevControl']) as Function)({ disabled: !canPrev() || props.disabled, goto: goPrev, page: currentPage() - 1 }) : <button type="button" className={"rozie-pagination-control rozie-pagination-prev"} data-page-control="" tabIndex={tabIndexFor(true)} disabled={!canPrev() || props.disabled} aria-disabled={!!(!canPrev() || props.disabled)} aria-label="Previous page" onClick={goPrev} data-rozie-s-de247ae2="">‹</button>}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
{model().pages.map((item, index) => <Fragment key={item === 'ellipsis' ? 'ellipsis-' + index : item}>
|
|
267
|
+
{!!(item === 'ellipsis') && <span className={"rozie-pagination-ellipsis"} aria-hidden="true" data-rozie-s-de247ae2="">
|
|
268
|
+
{(props.renderEllipsis ?? props.slots?.['ellipsis']) ? ((props.renderEllipsis ?? props.slots?.['ellipsis']) as Function)({ index }) : "…"}
|
|
269
|
+
</span>}{!!(item !== 'ellipsis') && <span className={"rozie-pagination-item"} data-rozie-s-de247ae2="">
|
|
270
|
+
{(props.renderItem ?? props.slots?.['item']) ? ((props.renderItem ?? props.slots?.['item']) as Function)({ page: item, selected: isActive(item), goto: () => goToPage(item) }) : <button type="button" className={clsx("rozie-pagination-page", { "is-active": isActive(item) })} data-page-control="" tabIndex={tabIndexFor(isActive(item))} disabled={!!props.disabled} aria-disabled={!!props.disabled} aria-current={rozieAttr(isActive(item) ? 'page' : undefined)} aria-label={rozieAttr('Go to page ' + item)} onClick={($event) => { goToPage(item); }} data-rozie-s-de247ae2="">{rozieDisplay(item)}</button>}
|
|
271
|
+
</span>}</Fragment>)}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
{(props.renderNextControl ?? props.slots?.['nextControl']) ? ((props.renderNextControl ?? props.slots?.['nextControl']) as Function)({ disabled: !canNext() || props.disabled, goto: goNext, page: currentPage() + 1 }) : <button type="button" className={"rozie-pagination-control rozie-pagination-next"} data-page-control="" tabIndex={tabIndexFor(true)} disabled={!canNext() || props.disabled} aria-disabled={!!(!canNext() || props.disabled)} aria-label="Next page" onClick={goNext} data-rozie-s-de247ae2="">›</button>}
|
|
275
|
+
</nav>
|
|
276
|
+
</>
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
export default Pagination;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { default as Pagination } from './Pagination';
|
|
2
|
+
export { default } from './Pagination';
|
|
3
|
+
|
|
4
|
+
/** The `$expose` imperative handle received via `ref` — { goto, next, prev, first, last }. */
|
|
5
|
+
export type { PaginationHandle } from './Pagination';
|
|
6
|
+
export type * from './Pagination';
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paginationItems — the pure page-item model for the Pagination family.
|
|
3
|
+
*
|
|
4
|
+
* THE branchy core of this no-engine family, extracted to `src/internal/` so it
|
|
5
|
+
* can be unit-tested in isolation (codegen vendors `src/internal/` into every
|
|
6
|
+
* leaf via copyInternal, excluding `*.test.ts`) and imported once from
|
|
7
|
+
* `Pagination.rozie`'s `<script>` as a PLAIN function — never a `$computed`,
|
|
8
|
+
* since a `$computed` is a value on React but an accessor on Solid, so aliasing
|
|
9
|
+
* the result in script logic diverges across targets. A plain function called
|
|
10
|
+
* `()` everywhere is uniform on all six.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the MUI / WAI-ARIA "windowed pager" model: a fixed number of pages at
|
|
13
|
+
* each boundary (`boundaryCount`), a window of `siblingCount` pages on each side
|
|
14
|
+
* of the current page, and an `'ellipsis'` marker wherever a gap is collapsed.
|
|
15
|
+
* The returned `pages` array is FRESH on every call (do not feed it to a
|
|
16
|
+
* reference-equality `$watch` getter — watch a primitive instead).
|
|
17
|
+
*
|
|
18
|
+
* No framework imports, no DOM — pure data in, pure data out.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type PaginationItem = number | 'ellipsis';
|
|
22
|
+
|
|
23
|
+
export interface PaginationInput {
|
|
24
|
+
/** 1-based current page. Clamped into [1, totalPages]. */
|
|
25
|
+
page: number;
|
|
26
|
+
/** Explicit total page count. Wins over `total`/`pageSize` when > 0. */
|
|
27
|
+
totalPages?: number | null;
|
|
28
|
+
/** Total item count — used with `pageSize` when `totalPages` is absent. */
|
|
29
|
+
total?: number | null;
|
|
30
|
+
/** Items per page — used with `total` when `totalPages` is absent. */
|
|
31
|
+
pageSize?: number | null;
|
|
32
|
+
/** Pages shown on each side of the current page. */
|
|
33
|
+
siblingCount?: number;
|
|
34
|
+
/** Pages always shown at each boundary (start + end). */
|
|
35
|
+
boundaryCount?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PaginationModel {
|
|
39
|
+
/** The resolved effective total page count (always >= 1). */
|
|
40
|
+
totalPages: number;
|
|
41
|
+
/** The clamped 1-based current page (always in [1, totalPages]). */
|
|
42
|
+
page: number;
|
|
43
|
+
/** The ordered render model: page numbers interleaved with `'ellipsis'`. */
|
|
44
|
+
pages: PaginationItem[];
|
|
45
|
+
/** `true` when a previous page exists (page > 1). */
|
|
46
|
+
hasPrev: boolean;
|
|
47
|
+
/** `true` when a next page exists (page < totalPages). */
|
|
48
|
+
hasNext: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Inclusive integer range [start, end]; empty when start > end. */
|
|
52
|
+
function range(start: number, end: number): number[] {
|
|
53
|
+
const out: number[] = [];
|
|
54
|
+
for (let i = start; i <= end; i++) out.push(i);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the effective total page count from either an explicit `totalPages`
|
|
60
|
+
* or `ceil(total / pageSize)`. Always at least 1 (an empty data set still has a
|
|
61
|
+
* single page).
|
|
62
|
+
*/
|
|
63
|
+
export function resolveTotalPages(input: PaginationInput): number {
|
|
64
|
+
const tp = input.totalPages;
|
|
65
|
+
if (typeof tp === 'number' && tp > 0) return Math.max(1, Math.floor(tp));
|
|
66
|
+
const total = typeof input.total === 'number' ? input.total : 0;
|
|
67
|
+
const size = typeof input.pageSize === 'number' && input.pageSize > 0 ? input.pageSize : 0;
|
|
68
|
+
if (total > 0 && size > 0) return Math.max(1, Math.ceil(total / size));
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the windowed page-item model. Pure: no clamping side effects, no DOM.
|
|
74
|
+
*
|
|
75
|
+
* Algorithm (MUI usePagination semantics):
|
|
76
|
+
* - `startPages` = first `boundaryCount` pages; `endPages` = last
|
|
77
|
+
* `boundaryCount` pages.
|
|
78
|
+
* - a sibling window of `siblingCount` pages on each side of `page`, kept
|
|
79
|
+
* clear of the boundary blocks.
|
|
80
|
+
* - an `'ellipsis'` collapses a gap of 2+ pages; a gap of exactly one page is
|
|
81
|
+
* rendered as that single bridging page (never a 1-page ellipsis).
|
|
82
|
+
*/
|
|
83
|
+
export function paginationItems(input: PaginationInput): PaginationModel {
|
|
84
|
+
const totalPages = resolveTotalPages(input);
|
|
85
|
+
|
|
86
|
+
const rawPage = typeof input.page === 'number' && Number.isFinite(input.page) ? Math.floor(input.page) : 1;
|
|
87
|
+
const page = Math.min(Math.max(rawPage, 1), totalPages);
|
|
88
|
+
|
|
89
|
+
const siblingCount = Math.max(0, Math.floor(input.siblingCount ?? 1));
|
|
90
|
+
const boundaryCount = Math.max(0, Math.floor(input.boundaryCount ?? 1));
|
|
91
|
+
|
|
92
|
+
// Start/end boundary blocks (clamped to the available pages).
|
|
93
|
+
const startPages = range(1, Math.min(boundaryCount, totalPages));
|
|
94
|
+
const endPages = range(Math.max(totalPages - boundaryCount + 1, boundaryCount + 1), totalPages);
|
|
95
|
+
|
|
96
|
+
// The sibling window around the current page, kept clear of the boundary
|
|
97
|
+
// blocks so a boundary page is never duplicated inside the window.
|
|
98
|
+
const siblingsStart = Math.max(
|
|
99
|
+
Math.min(page - siblingCount, totalPages - boundaryCount - siblingCount * 2 - 1),
|
|
100
|
+
boundaryCount + 2,
|
|
101
|
+
);
|
|
102
|
+
const endPagesFirst: number | undefined = endPages[0];
|
|
103
|
+
const siblingsEnd = Math.min(
|
|
104
|
+
Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),
|
|
105
|
+
endPagesFirst !== undefined ? endPagesFirst - 2 : totalPages - 1,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const pages: PaginationItem[] = [];
|
|
109
|
+
|
|
110
|
+
pages.push(...startPages);
|
|
111
|
+
|
|
112
|
+
// Gap (or single bridging page) between the start block and the window.
|
|
113
|
+
if (siblingsStart > boundaryCount + 2) {
|
|
114
|
+
pages.push('ellipsis');
|
|
115
|
+
} else if (boundaryCount + 1 < totalPages - boundaryCount) {
|
|
116
|
+
pages.push(boundaryCount + 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
pages.push(...range(siblingsStart, siblingsEnd));
|
|
120
|
+
|
|
121
|
+
// Gap (or single bridging page) between the window and the end block.
|
|
122
|
+
if (siblingsEnd < totalPages - boundaryCount - 1) {
|
|
123
|
+
pages.push('ellipsis');
|
|
124
|
+
} else if (totalPages - boundaryCount > boundaryCount) {
|
|
125
|
+
pages.push(totalPages - boundaryCount);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
pages.push(...endPages);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
totalPages,
|
|
132
|
+
page,
|
|
133
|
+
pages,
|
|
134
|
+
hasPrev: page > 1,
|
|
135
|
+
hasNext: page < totalPages,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @rozie-ui/pagination — base token reference.
|
|
3
|
+
*
|
|
4
|
+
* Every COSMETIC value the pagination control renders is a
|
|
5
|
+
* `--rozie-pagination-*` custom property. The component ships with inline
|
|
6
|
+
* `var(token, fallback)` defaults, so importing this file is OPTIONAL — it
|
|
7
|
+
* simply makes the full token surface explicit in one place so you can
|
|
8
|
+
* copy/override exactly what you need.
|
|
9
|
+
*
|
|
10
|
+
* The structural rules (the inline-flex control row, the per-button box model,
|
|
11
|
+
* the focus-ring, the active page, and the ellipsis cell) live in the
|
|
12
|
+
* component's scoped `<style>` (per-leaf, NOT consumer-overridable). Only the
|
|
13
|
+
* values below flow through tokens.
|
|
14
|
+
*
|
|
15
|
+
* Override any token at any ancestor scope (`:root`, `.dark`, a wrapper, or the
|
|
16
|
+
* `.rozie-pagination` element). See themes/shadcn.css, themes/material.css and
|
|
17
|
+
* themes/bootstrap.css for design-system bridges.
|
|
18
|
+
*
|
|
19
|
+
* WHERE THE DEFAULTS SIT. This file declares no public token that could shadow yours. Each
|
|
20
|
+
* default is the FALLBACK of a private wiring line on the document root, under `:where(:root)`
|
|
21
|
+
* (zero specificity): `--rpg-x: var(--rozie-pagination-x, <default>)`. The component reads every token
|
|
22
|
+
* public-first, `var(--rozie-pagination-x, var(--rpg-x, <inline default>))`, so a value set on ANY
|
|
23
|
+
* ancestor (`:root`, `.dark`, a wrapper) or on the component itself wins by plain inheritance,
|
|
24
|
+
* in any import order, on all six targets (Lit included: custom properties inherit into its
|
|
25
|
+
* shadow root). A value an element declares for itself always beats one it would inherit,
|
|
26
|
+
* which is why nothing on the way to the component may declare a public token.
|
|
27
|
+
* A token whose default is ANOTHER token is not declared here at all: the component reads it
|
|
28
|
+
* as `var(<token>, var(<source>, …))`, so it follows the source as resolved AT the component —
|
|
29
|
+
* declared at the root it would be computed once, against the root's value.
|
|
30
|
+
* The lines that say `inherit` / `initial` stay plain declarations: at the root such a value
|
|
31
|
+
* is "unset", so the component's own fallback applies, and it never shadows an ancestor.
|
|
32
|
+
* A design-system bridge (themes/shadcn.css, material.css, bootstrap.css) re-declares only the
|
|
33
|
+
* wiring of the tokens it maps, with the design system's variable as the fallback, on the
|
|
34
|
+
* component (`.rozie-pagination` and the Lit host `rozie-pagination`) — so that variable is resolved AT the
|
|
35
|
+
* component and follows the nearest theme scope — and on `:root`, the copy a Lit component
|
|
36
|
+
* nested in another shadow root (out of reach of every document selector) inherits. The
|
|
37
|
+
* `--rpg-*` names are internal: not a stable API.
|
|
38
|
+
*/
|
|
39
|
+
:where(:root) {
|
|
40
|
+
/* layout / typography */
|
|
41
|
+
--rpg-gap: var(--rozie-pagination-gap, 0.25rem);
|
|
42
|
+
--rozie-pagination-font: inherit;
|
|
43
|
+
--rpg-font-weight: var(--rozie-pagination-font-weight, 500);
|
|
44
|
+
|
|
45
|
+
/* accent — the single color most consumers will set; the active page + focus
|
|
46
|
+
ring derive from it */
|
|
47
|
+
--rpg-accent: var(--rozie-pagination-accent, #0066cc);
|
|
48
|
+
|
|
49
|
+
/* per-control box model */
|
|
50
|
+
--rpg-size: var(--rozie-pagination-size, 2.25rem);
|
|
51
|
+
--rpg-padding-x: var(--rozie-pagination-padding-x, 0.5rem);
|
|
52
|
+
--rpg-fg: var(--rozie-pagination-fg, #1a1a1a);
|
|
53
|
+
--rpg-bg: var(--rozie-pagination-bg, transparent);
|
|
54
|
+
--rpg-border-width: var(--rozie-pagination-border-width, 1px);
|
|
55
|
+
--rpg-border: var(--rozie-pagination-border, rgba(0, 0, 0, 0.18));
|
|
56
|
+
--rpg-radius: var(--rozie-pagination-radius, 6px);
|
|
57
|
+
|
|
58
|
+
/* hover */
|
|
59
|
+
--rpg-hover-bg: var(--rozie-pagination-hover-bg, rgba(0, 0, 0, 0.05));
|
|
60
|
+
--rpg-hover-border: var(--rozie-pagination-hover-border, rgba(0, 0, 0, 0.28));
|
|
61
|
+
|
|
62
|
+
/* active (current) page */
|
|
63
|
+
--rpg-active-fg: var(--rozie-pagination-active-fg, #fff);
|
|
64
|
+
|
|
65
|
+
/* focus ring */
|
|
66
|
+
--rpg-ring-width: var(--rozie-pagination-ring-width, 2px);
|
|
67
|
+
--rpg-ring-offset: var(--rozie-pagination-ring-offset, 1px);
|
|
68
|
+
|
|
69
|
+
/* ellipsis marker */
|
|
70
|
+
--rpg-ellipsis-fg: var(--rozie-pagination-ellipsis-fg, rgba(0, 0, 0, 0.5));
|
|
71
|
+
|
|
72
|
+
/* disabled */
|
|
73
|
+
--rpg-disabled-opacity: var(--rozie-pagination-disabled-opacity, 0.5);
|
|
74
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @rozie-ui/pagination — Bootstrap 5 bridge.
|
|
3
|
+
*
|
|
4
|
+
* Maps the pagination tokens onto Bootstrap's `--bs-*` CSS variables (Bootstrap
|
|
5
|
+
* 5.2+). Reading them live means the control follows `data-bs-theme="dark"`
|
|
6
|
+
* and any custom `$primary` build automatically. Falls back to Bootstrap's
|
|
7
|
+
* default light palette when the vars are absent.
|
|
8
|
+
*
|
|
9
|
+
* import '@rozie-ui/pagination-react/themes/bootstrap.css';
|
|
10
|
+
*
|
|
11
|
+
* HOW IT MAPS. This bridge declares no public token: it re-declares the private wiring of
|
|
12
|
+
* each token it maps, with the design system's variable as the fallback
|
|
13
|
+
* (`--rpg-x: var(--rozie-pagination-x, <design-system variable>)`). The component reads every token
|
|
14
|
+
* public-first, so on all six targets:
|
|
15
|
+
* - a public `--rozie-pagination-*` token set on any ancestor (or on the component) wins;
|
|
16
|
+
* - otherwise the design system's variable applies, resolved AT the component (`.rozie-pagination`
|
|
17
|
+
* and the Lit host `rozie-pagination`), so a `.dark` / `data-bs-theme` wrapper narrower than
|
|
18
|
+
* <html> is followed;
|
|
19
|
+
* - a Lit component nested in another shadow root, which no document selector reaches,
|
|
20
|
+
* inherits the `:root` copy instead: there the design system's variables are read at
|
|
21
|
+
* the document root.
|
|
22
|
+
* This rule outranks base.css's `:where(:root)` wiring, so import order does not matter.
|
|
23
|
+
*/
|
|
24
|
+
:root,
|
|
25
|
+
.rozie-pagination,
|
|
26
|
+
rozie-pagination {
|
|
27
|
+
--rpg-accent: var(--rozie-pagination-accent, var(--bs-primary, #0d6efd));
|
|
28
|
+
|
|
29
|
+
--rpg-fg: var(--rozie-pagination-fg, var(--bs-link-color, #0d6efd));
|
|
30
|
+
--rpg-bg: var(--rozie-pagination-bg, var(--bs-body-bg, #fff));
|
|
31
|
+
--rpg-border: var(--rozie-pagination-border, var(--bs-border-color, #dee2e6));
|
|
32
|
+
--rpg-radius: var(--rozie-pagination-radius, var(--bs-border-radius, 0.375rem));
|
|
33
|
+
|
|
34
|
+
--rpg-hover-bg: var(--rozie-pagination-hover-bg, var(--bs-tertiary-bg, #e9ecef));
|
|
35
|
+
--rpg-hover-border: var(--rozie-pagination-hover-border, var(--bs-border-color, #dee2e6));
|
|
36
|
+
|
|
37
|
+
--rpg-active-fg: var(--rozie-pagination-active-fg, #fff);
|
|
38
|
+
--rpg-active-bg: var(--rozie-pagination-active-bg, var(--bs-primary, #0d6efd));
|
|
39
|
+
--rpg-active-border: var(--rozie-pagination-active-border, var(--bs-primary, #0d6efd));
|
|
40
|
+
|
|
41
|
+
--rpg-ring: var(--rozie-pagination-ring, rgba(var(--bs-primary-rgb, 13, 110, 253), 0.5));
|
|
42
|
+
|
|
43
|
+
--rpg-ellipsis-fg: var(--rozie-pagination-ellipsis-fg, var(--bs-secondary-color, #6c757d));
|
|
44
|
+
|
|
45
|
+
--rpg-disabled-opacity: var(--rozie-pagination-disabled-opacity, 0.5);
|
|
46
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @rozie-ui/pagination — Material Design 3 bridge.
|
|
3
|
+
*
|
|
4
|
+
* Maps the pagination tokens onto the MD3 system color roles
|
|
5
|
+
* (`--md-sys-color-*`) emitted by material-web / @material/material-color-utilities
|
|
6
|
+
* theme builders. Falls back to the baseline M3 light palette when those vars
|
|
7
|
+
* are absent, so it renders correctly even without a generated theme.
|
|
8
|
+
*
|
|
9
|
+
* import '@rozie-ui/pagination-react/themes/material.css';
|
|
10
|
+
*
|
|
11
|
+
* HOW IT MAPS. This bridge declares no public token: it re-declares the private wiring of
|
|
12
|
+
* each token it maps, with the design system's variable as the fallback
|
|
13
|
+
* (`--rpg-x: var(--rozie-pagination-x, <design-system variable>)`). The component reads every token
|
|
14
|
+
* public-first, so on all six targets:
|
|
15
|
+
* - a public `--rozie-pagination-*` token set on any ancestor (or on the component) wins;
|
|
16
|
+
* - otherwise the design system's variable applies, resolved AT the component (`.rozie-pagination`
|
|
17
|
+
* and the Lit host `rozie-pagination`), so a `.dark` / `data-bs-theme` wrapper narrower than
|
|
18
|
+
* <html> is followed;
|
|
19
|
+
* - a Lit component nested in another shadow root, which no document selector reaches,
|
|
20
|
+
* inherits the `:root` copy instead: there the design system's variables are read at
|
|
21
|
+
* the document root.
|
|
22
|
+
* This rule outranks base.css's `:where(:root)` wiring, so import order does not matter.
|
|
23
|
+
*/
|
|
24
|
+
:root,
|
|
25
|
+
.rozie-pagination,
|
|
26
|
+
rozie-pagination {
|
|
27
|
+
--rpg-accent: var(--rozie-pagination-accent, var(--md-sys-color-primary, #6750a4));
|
|
28
|
+
|
|
29
|
+
--rpg-fg: var(--rozie-pagination-fg, var(--md-sys-color-on-surface, #1d1b20));
|
|
30
|
+
--rpg-bg: var(--rozie-pagination-bg, var(--md-sys-color-surface, #fef7ff));
|
|
31
|
+
--rpg-border: var(--rozie-pagination-border, var(--md-sys-color-outline, #79747e));
|
|
32
|
+
--rpg-radius: var(--rozie-pagination-radius, 1rem);
|
|
33
|
+
|
|
34
|
+
--rpg-hover-bg: var(--rozie-pagination-hover-bg, color-mix(in srgb, var(--md-sys-color-primary, #6750a4) 8%, transparent));
|
|
35
|
+
--rpg-hover-border: var(--rozie-pagination-hover-border, var(--md-sys-color-outline, #79747e));
|
|
36
|
+
|
|
37
|
+
--rpg-active-fg: var(--rozie-pagination-active-fg, var(--md-sys-color-on-primary, #fff));
|
|
38
|
+
--rpg-active-bg: var(--rozie-pagination-active-bg, var(--md-sys-color-primary, #6750a4));
|
|
39
|
+
--rpg-active-border: var(--rozie-pagination-active-border, var(--md-sys-color-primary, #6750a4));
|
|
40
|
+
|
|
41
|
+
--rpg-ring: var(--rozie-pagination-ring, var(--md-sys-color-primary, #6750a4));
|
|
42
|
+
|
|
43
|
+
--rpg-ellipsis-fg: var(--rozie-pagination-ellipsis-fg, var(--md-sys-color-on-surface-variant, #49454f));
|
|
44
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @rozie-ui/pagination — shadcn/ui (Radix) bridge.
|
|
3
|
+
*
|
|
4
|
+
* Maps the pagination tokens onto the shadcn/ui CSS variables defined on
|
|
5
|
+
* `:root` / `.dark` (the classic HSL-channel convention: `--background: 0 0%
|
|
6
|
+
* 100%` consumed as `hsl(var(--background))`). Because it reads those vars live,
|
|
7
|
+
* the control automatically follows light/dark theme switches and accent
|
|
8
|
+
* changes.
|
|
9
|
+
*
|
|
10
|
+
* import '@rozie-ui/pagination-react/themes/shadcn.css';
|
|
11
|
+
*
|
|
12
|
+
* If your shadcn setup predates the `hsl()`-wrapping convention (Tailwind v4 /
|
|
13
|
+
* oklch tokens emit pre-formed colors), drop the `hsl(...)` wrappers below.
|
|
14
|
+
*
|
|
15
|
+
* HOW IT MAPS. This bridge declares no public token: it re-declares the private wiring of
|
|
16
|
+
* each token it maps, with the design system's variable as the fallback
|
|
17
|
+
* (`--rpg-x: var(--rozie-pagination-x, <design-system variable>)`). The component reads every token
|
|
18
|
+
* public-first, so on all six targets:
|
|
19
|
+
* - a public `--rozie-pagination-*` token set on any ancestor (or on the component) wins;
|
|
20
|
+
* - otherwise the design system's variable applies, resolved AT the component (`.rozie-pagination`
|
|
21
|
+
* and the Lit host `rozie-pagination`), so a `.dark` / `data-bs-theme` wrapper narrower than
|
|
22
|
+
* <html> is followed;
|
|
23
|
+
* - a Lit component nested in another shadow root, which no document selector reaches,
|
|
24
|
+
* inherits the `:root` copy instead: there the design system's variables are read at
|
|
25
|
+
* the document root.
|
|
26
|
+
* This rule outranks base.css's `:where(:root)` wiring, so import order does not matter.
|
|
27
|
+
*/
|
|
28
|
+
:root,
|
|
29
|
+
.rozie-pagination,
|
|
30
|
+
rozie-pagination {
|
|
31
|
+
--rpg-accent: var(--rozie-pagination-accent, hsl(var(--primary, 222.2 47.4% 11.2%)));
|
|
32
|
+
|
|
33
|
+
--rpg-fg: var(--rozie-pagination-fg, hsl(var(--foreground, 222.2 84% 4.9%)));
|
|
34
|
+
--rpg-bg: var(--rozie-pagination-bg, hsl(var(--background, 0 0% 100%)));
|
|
35
|
+
--rpg-border: var(--rozie-pagination-border, hsl(var(--border, 214.3 31.8% 91.4%)));
|
|
36
|
+
--rpg-radius: var(--rozie-pagination-radius, var(--radius, 0.5rem));
|
|
37
|
+
|
|
38
|
+
--rpg-hover-bg: var(--rozie-pagination-hover-bg, hsl(var(--accent, 210 40% 96.1%)));
|
|
39
|
+
--rpg-hover-border: var(--rozie-pagination-hover-border, hsl(var(--border, 214.3 31.8% 91.4%)));
|
|
40
|
+
|
|
41
|
+
--rpg-active-fg: var(--rozie-pagination-active-fg, hsl(var(--primary-foreground, 210 40% 98%)));
|
|
42
|
+
--rpg-active-bg: var(--rozie-pagination-active-bg, hsl(var(--primary, 222.2 47.4% 11.2%)));
|
|
43
|
+
--rpg-active-border: var(--rozie-pagination-active-border, hsl(var(--primary, 222.2 47.4% 11.2%)));
|
|
44
|
+
|
|
45
|
+
--rpg-ring: var(--rozie-pagination-ring, hsl(var(--ring, 222.2 84% 4.9%)));
|
|
46
|
+
|
|
47
|
+
--rpg-ellipsis-fg: var(--rozie-pagination-ellipsis-fg, hsl(var(--muted-foreground, 215.4 16.3% 46.9%)));
|
|
48
|
+
}
|