@open-mercato/ui 0.6.8-develop.7029.1.a1bb3363af → 0.6.8-develop.7031.1.005201cd70
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/dist/backend/DataTable.js +16 -3
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/utils/crud.js.map +2 -2
- package/dist/primitives/pagination.js +14 -9
- package/dist/primitives/pagination.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +36 -5
- package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +122 -0
- package/src/backend/utils/crud.ts +2 -0
- package/src/primitives/__tests__/pagination.test.tsx +54 -0
- package/src/primitives/pagination.tsx +40 -9
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/utils/crud.ts"],
|
|
4
|
-
"sourcesContent": ["export type SortDir = 'asc' | 'desc'\n\nexport type ListResponse<T> = {\n items: T[]\n total: number\n page: number\n pageSize: number\n totalPages: number\n}\n\nexport type CrudExportFormat = 'csv' | 'json' | 'xml' | 'markdown'\n\nfunction toQuery(params: Record<string, any>) {\n const sp = new URLSearchParams()\n for (const [k, v] of Object.entries(params)) {\n if (v === undefined || v === null) continue\n if (Array.isArray(v)) {\n if (v.length === 0) continue\n sp.set(k, v.join(','))\n } else {\n sp.set(k, String(v))\n }\n }\n return sp.toString()\n}\n\nexport function buildCrudQuery(params: Record<string, any>): string {\n return toQuery(params)\n}\n\nimport { apiCall, readApiResultOrThrow, type ApiCallResult } from './apiCall'\nimport { raiseCrudError } from './serverErrors'\n\nfunction mergeHeaders(base: HeadersInit | undefined, extra: Record<string, string>): HeadersInit {\n if (!base) return extra\n const hasHeadersCtor = typeof Headers !== 'undefined'\n if (hasHeadersCtor && base instanceof Headers) {\n const merged = new Headers(base)\n Object.entries(extra).forEach(([key, value]) => merged.set(key, value))\n return merged\n }\n if (Array.isArray(base)) {\n return [...base, ...Object.entries(extra)]\n }\n return { ...(base as Record<string, string>), ...extra }\n}\n\ntype CrudRequestExtras<TReturn> = {\n parseResult?: (res: Response) => Promise<TReturn | null>\n fallbackResult?: TReturn | null\n errorMessage?: string\n}\n\nexport type CrudRequestInit<TReturn> = Omit<RequestInit, 'body' | 'method'> & CrudRequestExtras<TReturn>\ntype CrudDeleteOptions<TReturn> = Omit<RequestInit, 'method' | 'body'> &\n CrudRequestExtras<TReturn> & {\n body?: unknown\n id?: string\n }\n\nexport type CrudResponse<TReturn> = ApiCallResult<TReturn>\n\nexport async function fetchCrudList<T>(apiPath: string, params: Record<string, any>, init?: RequestInit): Promise<ListResponse<T>> {\n const qs = buildCrudQuery(params)\n return readApiResultOrThrow<ListResponse<T>>(`/api/${apiPath}?${qs}`, init, {\n errorMessage: 'Failed to fetch list',\n })\n}\n\nexport function buildCrudExportUrl(apiPath: string, params: Record<string, any>, format: CrudExportFormat): string {\n const qs = buildCrudQuery({ ...params, format })\n return `/api/${apiPath}?${qs}`\n}\n\nexport function buildCrudCsvUrl(apiPath: string, params: Record<string, any>): string {\n return buildCrudExportUrl(apiPath, params, 'csv')\n}\n\nexport async function createCrud<TReturn = Record<string, unknown>>(\n apiPath: string,\n body: any,\n init?: CrudRequestInit<TReturn>,\n): Promise<CrudResponse<TReturn>> {\n const { parseResult, fallbackResult, errorMessage, headers, ...rest } = init ?? {}\n const call = await apiCall<TReturn>(\n `/api/${apiPath}`,\n {\n ...rest,\n method: 'POST',\n headers: mergeHeaders(headers, { 'content-type': 'application/json' }),\n body: JSON.stringify(body),\n },\n {\n parse: parseResult,\n fallback: fallbackResult ?? null,\n },\n )\n if (!call.ok) await raiseCrudError(call.response, errorMessage ?? 'Failed to create')\n return call\n}\n\nexport async function updateCrud<TReturn = Record<string, unknown>>(\n apiPath: string,\n body: any,\n init?: CrudRequestInit<TReturn>,\n): Promise<CrudResponse<TReturn>> {\n const { parseResult, fallbackResult, errorMessage, headers, ...rest } = init ?? {}\n const call = await apiCall<TReturn>(\n `/api/${apiPath}`,\n {\n ...rest,\n method: 'PUT',\n headers: mergeHeaders(headers, { 'content-type': 'application/json' }),\n body: JSON.stringify(body),\n },\n {\n parse: parseResult,\n fallback: fallbackResult ?? null,\n },\n )\n if (!call.ok) await raiseCrudError(call.response, errorMessage ?? 'Failed to update')\n return call\n}\n\nexport async function deleteCrud<TReturn = Record<string, unknown>>(\n apiPath: string,\n id: string,\n init?: CrudRequestInit<TReturn>,\n): Promise<CrudResponse<TReturn>>\nexport async function deleteCrud<TReturn = Record<string, unknown>>(\n apiPath: string,\n options: CrudDeleteOptions<TReturn>,\n): Promise<CrudResponse<TReturn>>\nexport async function deleteCrud<TReturn = Record<string, unknown>>(\n apiPath: string,\n idOrOptions: string | CrudDeleteOptions<TReturn>,\n maybeInit?: CrudRequestInit<TReturn>,\n): Promise<CrudResponse<TReturn>> {\n if (typeof idOrOptions === 'string') {\n const { parseResult, fallbackResult, errorMessage, ...rest } = maybeInit ?? {}\n const call = await apiCall<TReturn>(\n `/api/${apiPath}?id=${encodeURIComponent(idOrOptions)}`,\n {\n ...rest,\n method: 'DELETE',\n },\n {\n parse: parseResult,\n fallback: fallbackResult ?? null,\n },\n )\n if (!call.ok) await raiseCrudError(call.response, errorMessage ?? 'Failed to delete')\n return call\n }\n const { parseResult, fallbackResult, errorMessage, headers, body, id, ...rest } = idOrOptions\n const payload = body ?? (id ? { id } : undefined)\n const requestHeaders =\n payload !== undefined ? mergeHeaders(headers, { 'content-type': 'application/json' }) : headers\n const call = await apiCall<TReturn>(\n `/api/${apiPath}`,\n {\n ...rest,\n method: 'DELETE',\n headers: requestHeaders,\n body: payload !== undefined ? JSON.stringify(payload) : undefined,\n },\n {\n parse: parseResult,\n fallback: fallbackResult ?? null,\n },\n )\n if (!call.ok) await raiseCrudError(call.response, errorMessage ?? 'Failed to delete')\n return call\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["export type SortDir = 'asc' | 'desc'\n\nexport type ListResponse<T> = {\n items: T[]\n total: number\n page: number\n pageSize: number\n totalPages: number\n /** Present (true) when the server capped the count: `total`/`totalPages` are floors. */\n totalIsCapped?: boolean\n}\n\nexport type CrudExportFormat = 'csv' | 'json' | 'xml' | 'markdown'\n\nfunction toQuery(params: Record<string, any>) {\n const sp = new URLSearchParams()\n for (const [k, v] of Object.entries(params)) {\n if (v === undefined || v === null) continue\n if (Array.isArray(v)) {\n if (v.length === 0) continue\n sp.set(k, v.join(','))\n } else {\n sp.set(k, String(v))\n }\n }\n return sp.toString()\n}\n\nexport function buildCrudQuery(params: Record<string, any>): string {\n return toQuery(params)\n}\n\nimport { apiCall, readApiResultOrThrow, type ApiCallResult } from './apiCall'\nimport { raiseCrudError } from './serverErrors'\n\nfunction mergeHeaders(base: HeadersInit | undefined, extra: Record<string, string>): HeadersInit {\n if (!base) return extra\n const hasHeadersCtor = typeof Headers !== 'undefined'\n if (hasHeadersCtor && base instanceof Headers) {\n const merged = new Headers(base)\n Object.entries(extra).forEach(([key, value]) => merged.set(key, value))\n return merged\n }\n if (Array.isArray(base)) {\n return [...base, ...Object.entries(extra)]\n }\n return { ...(base as Record<string, string>), ...extra }\n}\n\ntype CrudRequestExtras<TReturn> = {\n parseResult?: (res: Response) => Promise<TReturn | null>\n fallbackResult?: TReturn | null\n errorMessage?: string\n}\n\nexport type CrudRequestInit<TReturn> = Omit<RequestInit, 'body' | 'method'> & CrudRequestExtras<TReturn>\ntype CrudDeleteOptions<TReturn> = Omit<RequestInit, 'method' | 'body'> &\n CrudRequestExtras<TReturn> & {\n body?: unknown\n id?: string\n }\n\nexport type CrudResponse<TReturn> = ApiCallResult<TReturn>\n\nexport async function fetchCrudList<T>(apiPath: string, params: Record<string, any>, init?: RequestInit): Promise<ListResponse<T>> {\n const qs = buildCrudQuery(params)\n return readApiResultOrThrow<ListResponse<T>>(`/api/${apiPath}?${qs}`, init, {\n errorMessage: 'Failed to fetch list',\n })\n}\n\nexport function buildCrudExportUrl(apiPath: string, params: Record<string, any>, format: CrudExportFormat): string {\n const qs = buildCrudQuery({ ...params, format })\n return `/api/${apiPath}?${qs}`\n}\n\nexport function buildCrudCsvUrl(apiPath: string, params: Record<string, any>): string {\n return buildCrudExportUrl(apiPath, params, 'csv')\n}\n\nexport async function createCrud<TReturn = Record<string, unknown>>(\n apiPath: string,\n body: any,\n init?: CrudRequestInit<TReturn>,\n): Promise<CrudResponse<TReturn>> {\n const { parseResult, fallbackResult, errorMessage, headers, ...rest } = init ?? {}\n const call = await apiCall<TReturn>(\n `/api/${apiPath}`,\n {\n ...rest,\n method: 'POST',\n headers: mergeHeaders(headers, { 'content-type': 'application/json' }),\n body: JSON.stringify(body),\n },\n {\n parse: parseResult,\n fallback: fallbackResult ?? null,\n },\n )\n if (!call.ok) await raiseCrudError(call.response, errorMessage ?? 'Failed to create')\n return call\n}\n\nexport async function updateCrud<TReturn = Record<string, unknown>>(\n apiPath: string,\n body: any,\n init?: CrudRequestInit<TReturn>,\n): Promise<CrudResponse<TReturn>> {\n const { parseResult, fallbackResult, errorMessage, headers, ...rest } = init ?? {}\n const call = await apiCall<TReturn>(\n `/api/${apiPath}`,\n {\n ...rest,\n method: 'PUT',\n headers: mergeHeaders(headers, { 'content-type': 'application/json' }),\n body: JSON.stringify(body),\n },\n {\n parse: parseResult,\n fallback: fallbackResult ?? null,\n },\n )\n if (!call.ok) await raiseCrudError(call.response, errorMessage ?? 'Failed to update')\n return call\n}\n\nexport async function deleteCrud<TReturn = Record<string, unknown>>(\n apiPath: string,\n id: string,\n init?: CrudRequestInit<TReturn>,\n): Promise<CrudResponse<TReturn>>\nexport async function deleteCrud<TReturn = Record<string, unknown>>(\n apiPath: string,\n options: CrudDeleteOptions<TReturn>,\n): Promise<CrudResponse<TReturn>>\nexport async function deleteCrud<TReturn = Record<string, unknown>>(\n apiPath: string,\n idOrOptions: string | CrudDeleteOptions<TReturn>,\n maybeInit?: CrudRequestInit<TReturn>,\n): Promise<CrudResponse<TReturn>> {\n if (typeof idOrOptions === 'string') {\n const { parseResult, fallbackResult, errorMessage, ...rest } = maybeInit ?? {}\n const call = await apiCall<TReturn>(\n `/api/${apiPath}?id=${encodeURIComponent(idOrOptions)}`,\n {\n ...rest,\n method: 'DELETE',\n },\n {\n parse: parseResult,\n fallback: fallbackResult ?? null,\n },\n )\n if (!call.ok) await raiseCrudError(call.response, errorMessage ?? 'Failed to delete')\n return call\n }\n const { parseResult, fallbackResult, errorMessage, headers, body, id, ...rest } = idOrOptions\n const payload = body ?? (id ? { id } : undefined)\n const requestHeaders =\n payload !== undefined ? mergeHeaders(headers, { 'content-type': 'application/json' }) : headers\n const call = await apiCall<TReturn>(\n `/api/${apiPath}`,\n {\n ...rest,\n method: 'DELETE',\n headers: requestHeaders,\n body: payload !== undefined ? JSON.stringify(payload) : undefined,\n },\n {\n parse: parseResult,\n fallback: fallbackResult ?? null,\n },\n )\n if (!call.ok) await raiseCrudError(call.response, errorMessage ?? 'Failed to delete')\n return call\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["parseResult", "fallbackResult", "errorMessage", "rest", "call"]
|
|
7
7
|
}
|
|
@@ -76,6 +76,8 @@ const Pagination = React.forwardRef(
|
|
|
76
76
|
page,
|
|
77
77
|
pageSize,
|
|
78
78
|
total,
|
|
79
|
+
totalIsCapped = false,
|
|
80
|
+
hasNextPage,
|
|
79
81
|
onPageChange,
|
|
80
82
|
onPageSizeChange,
|
|
81
83
|
pageSizeOptions = [10, 25, 50, 100],
|
|
@@ -91,22 +93,25 @@ const Pagination = React.forwardRef(
|
|
|
91
93
|
...props
|
|
92
94
|
}, ref) => {
|
|
93
95
|
const t = useT();
|
|
94
|
-
const resolvedFormatPageInfo = formatPageInfo ?? ((p, total2) => t("ui.pagination.info.pageOf", "Page {page} of {total}", { page: p, total: total2 }));
|
|
96
|
+
const resolvedFormatPageInfo = formatPageInfo ?? ((p, total2) => totalIsCapped ? t("ui.pagination.info.pageOfCapped", "Page {page} of {total}+", { page: p, total: total2 }) : t("ui.pagination.info.pageOf", "Page {page} of {total}", { page: p, total: total2 }));
|
|
95
97
|
const resolvedFormatPageSizeLabel = formatPageSizeLabel ?? ((size) => t("ui.pagination.itemsPerPage.label", "{size} / page", { size }));
|
|
96
98
|
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
|
97
|
-
const safePage = Math.min(Math.max(1, page), totalPages);
|
|
99
|
+
const safePage = totalIsCapped ? Math.max(1, page) : Math.min(Math.max(1, page), totalPages);
|
|
100
|
+
const listPages = Math.max(totalPages, safePage);
|
|
101
|
+
const canGoNext = totalIsCapped ? hasNextPage ?? safePage < listPages : safePage < totalPages;
|
|
98
102
|
const items = React.useMemo(
|
|
99
|
-
() => buildPaginationItems(safePage,
|
|
100
|
-
[safePage,
|
|
103
|
+
() => buildPaginationItems(safePage, listPages, siblingCount, boundaryCount),
|
|
104
|
+
[safePage, listPages, siblingCount, boundaryCount]
|
|
101
105
|
);
|
|
102
106
|
const showPageSize = showPageSizeProp ?? Boolean(onPageSizeChange);
|
|
103
107
|
const goTo = React.useCallback(
|
|
104
108
|
(next) => {
|
|
105
109
|
if (disabled) return;
|
|
106
|
-
const
|
|
110
|
+
const upperBound = totalIsCapped ? Math.max(listPages, safePage + 1) : totalPages;
|
|
111
|
+
const bounded = Math.min(Math.max(1, next), upperBound);
|
|
107
112
|
if (bounded !== safePage) onPageChange(bounded);
|
|
108
113
|
},
|
|
109
|
-
[disabled, onPageChange, safePage, totalPages]
|
|
114
|
+
[disabled, onPageChange, safePage, totalPages, totalIsCapped, listPages]
|
|
110
115
|
);
|
|
111
116
|
return /* @__PURE__ */ jsxs(
|
|
112
117
|
"nav",
|
|
@@ -122,7 +127,7 @@ const Pagination = React.forwardRef(
|
|
|
122
127
|
{
|
|
123
128
|
"data-slot": "pagination-info",
|
|
124
129
|
className: "shrink-0 text-sm text-muted-foreground tabular-nums",
|
|
125
|
-
children: resolvedFormatPageInfo(safePage, totalPages)
|
|
130
|
+
children: resolvedFormatPageInfo(safePage, totalIsCapped ? listPages : totalPages)
|
|
126
131
|
}
|
|
127
132
|
) : /* @__PURE__ */ jsx("div", {}),
|
|
128
133
|
/* @__PURE__ */ jsxs(
|
|
@@ -197,13 +202,13 @@ const Pagination = React.forwardRef(
|
|
|
197
202
|
type: "button",
|
|
198
203
|
"data-slot": "pagination-next",
|
|
199
204
|
"aria-label": t("ui.pagination.next.ariaLabel", "Next page"),
|
|
200
|
-
disabled: disabled ||
|
|
205
|
+
disabled: disabled || !canGoNext,
|
|
201
206
|
onClick: () => goTo(safePage + 1),
|
|
202
207
|
className: cn(navButtonVariants()),
|
|
203
208
|
children: /* @__PURE__ */ jsx(ChevronRight, { "aria-hidden": "true", className: "size-5" })
|
|
204
209
|
}
|
|
205
210
|
) : null,
|
|
206
|
-
showFirstLast ? /* @__PURE__ */ jsx(
|
|
211
|
+
showFirstLast && !totalIsCapped ? /* @__PURE__ */ jsx(
|
|
207
212
|
"button",
|
|
208
213
|
{
|
|
209
214
|
type: "button",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/primitives/pagination.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport {\n ChevronLeft,\n ChevronRight,\n ChevronsLeft,\n ChevronsRight,\n} from 'lucide-react'\nimport { cva, type VariantProps } from 'class-variance-authority'\n\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n CompactSelectTrigger,\n Select,\n SelectContent,\n SelectItem,\n SelectValue,\n} from './compact-select'\n\n/**\n * Page navigation primitive per Figma `Pagination Group [1.1]` (DS Open\n * Mercato componentSet `199985:4135`).\n *\n * Layout (Figma `Basic` variant):\n *\n * [Left] \"Page 2 of 16\"\n * [Center] \u23EE \u25C0 [1][2]\u2026[N-1][N] \u25B6 \u23ED\n * [Right] \"7 / page\" CompactSelect\n *\n * Figma's three boolean variant props are mapped 1:1 to React props:\n *\n * \uD83E\uDD47 First / Last \u2192 `showFirstLast` (default true)\n * \u23ED\uFE0F Next / Previous \u2192 `showPrevNext` (default true)\n * \uD83E\uDDEA Advanced \u2192 `showInfo` + `showPageSize` (default both true)\n *\n * The numeric page list uses the same ellipsis algorithm Material /\n * MUI / shadcn ship: `boundaryCount` pages at each end (default 1),\n * `siblingCount` pages on either side of the current page (default 1),\n * collapse the rest into `\u2026` placeholders.\n *\n * ```tsx\n * const [page, setPage] = React.useState(1)\n * const [pageSize, setPageSize] = React.useState(25)\n * <Pagination\n * page={page}\n * pageSize={pageSize}\n * total={items.length}\n * onPageChange={setPage}\n * onPageSizeChange={setPageSize}\n * />\n *\n * // Compact (no first/last, no page-size select)\n * <Pagination\n * page={page}\n * pageSize={20}\n * total={120}\n * onPageChange={setPage}\n * showFirstLast={false}\n * showPageSize={false}\n * />\n * ```\n */\n\n/**\n * Build a stable list of pages + ellipsis placeholders that fits a\n * pagination row of `boundaryCount + 2 + siblingCount * 2 + 1` cells.\n * Returns `number` entries (1-indexed) and `'ellipsis-left'` /\n * `'ellipsis-right'` placeholders that the renderer paints as `\u2026`.\n *\n * Exported for tests + consumer reuse (e.g. a server-side rendered\n * pagination indicator that needs the same shape).\n */\nexport function buildPaginationItems(\n page: number,\n totalPages: number,\n siblingCount: number,\n boundaryCount: number,\n): Array<number | 'ellipsis-left' | 'ellipsis-right'> {\n if (totalPages <= 0) return []\n // When everything fits, just list every page.\n const totalSlots = boundaryCount * 2 + siblingCount * 2 + 3\n if (totalPages <= totalSlots) {\n return Array.from({ length: totalPages }, (_, i) => i + 1)\n }\n const startPages = Array.from({ length: boundaryCount }, (_, i) => i + 1)\n const endPages = Array.from(\n { length: boundaryCount },\n (_, i) => totalPages - boundaryCount + 1 + i,\n )\n const siblingStart = Math.max(\n Math.min(page - siblingCount, totalPages - boundaryCount - siblingCount * 2 - 1),\n boundaryCount + 2,\n )\n const siblingEnd = Math.min(\n Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),\n endPages.length > 0 ? endPages[0] - 2 : totalPages - 1,\n )\n const middle: Array<number> = []\n for (let i = siblingStart; i <= siblingEnd; i += 1) middle.push(i)\n\n // Bridge between the start boundary and the sibling window. The\n // ellipsis is only worth showing when \u22652 page numbers fall between\n // them; if exactly 1 page is missing, render that single number\n // instead of \"\u2026\" (cleaner UX \u2014 same width, no information loss).\n const result: Array<number | 'ellipsis-left' | 'ellipsis-right'> = []\n for (const p of startPages) result.push(p)\n if (siblingStart > boundaryCount + 2) {\n result.push('ellipsis-left')\n } else if (siblingStart === boundaryCount + 2) {\n result.push(boundaryCount + 1)\n }\n for (const p of middle) result.push(p)\n const firstEnd = endPages[0] ?? totalPages\n if (siblingEnd < firstEnd - 2) {\n result.push('ellipsis-right')\n } else if (siblingEnd === firstEnd - 2) {\n result.push(firstEnd - 1)\n }\n for (const p of endPages) result.push(p)\n return result\n}\n\nconst cellVariants = cva(\n 'inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-medium outline-none transition-colors tabular-nums ' +\n 'focus-visible:shadow-focus ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n selected: {\n true: 'bg-muted text-foreground',\n false: 'bg-background text-foreground hover:bg-muted/40',\n },\n },\n defaultVariants: { selected: false },\n },\n)\n\nconst navButtonVariants = cva(\n 'inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors ' +\n 'hover:bg-muted/40 hover:text-foreground ' +\n 'focus-visible:shadow-focus ' +\n 'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground',\n)\n\nexport type PaginationProps = React.HTMLAttributes<HTMLDivElement> & {\n /** Current 1-indexed page. */\n page: number\n /** Items per page. */\n pageSize: number\n /** Total item count. Used to derive `Math.ceil(total / pageSize)` pages. */\n total: number\n /** Called when the user changes page. */\n onPageChange: (next: number) => void\n /** Called when the user changes page size. Optional \u2014 when omitted,\n * the \"X / page\" select is hidden. */\n onPageSizeChange?: (next: number) => void\n /** Page size options for the select. Default `[10, 25, 50, 100]`. */\n pageSizeOptions?: readonly number[]\n /** Show the left \"Page X of Y\" indicator. Default `true`. */\n showInfo?: boolean\n /** Show the right \"X / page\" select. Default `true` when\n * `onPageSizeChange` is provided; ignored otherwise. */\n showPageSize?: boolean\n /** Show \u23EE / \u23ED first / last buttons. Default `true`. */\n showFirstLast?: boolean\n /** Show \u25C0 / \u25B6 prev / next buttons. Default `true`. */\n showPrevNext?: boolean\n /** Pages on either side of the current page in the page list. Default `1`. */\n siblingCount?: number\n /** Pages pinned at each end of the page list. Default `1`. */\n boundaryCount?: number\n /** Block all interactions. Default `false`. */\n disabled?: boolean\n /** ARIA label for the navigation landmark. Default `\"Pagination\"`. */\n 'aria-label'?: string\n /** Format the \"Page X of Y\" label. */\n formatPageInfo?: (page: number, totalPages: number) => string\n /** Format the \"X / page\" label. */\n formatPageSizeLabel?: (pageSize: number) => string\n}\n\nexport const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(\n (\n {\n className,\n page,\n pageSize,\n total,\n onPageChange,\n onPageSizeChange,\n pageSizeOptions = [10, 25, 50, 100],\n showInfo = true,\n showPageSize: showPageSizeProp,\n showFirstLast = true,\n showPrevNext = true,\n siblingCount = 1,\n boundaryCount = 1,\n disabled = false,\n formatPageInfo,\n formatPageSizeLabel,\n ...props\n },\n ref,\n ) => {\n const t = useT()\n const resolvedFormatPageInfo =\n formatPageInfo ??\n ((p: number, total: number) =>\n t('ui.pagination.info.pageOf', 'Page {page} of {total}', { page: p, total }))\n const resolvedFormatPageSizeLabel =\n formatPageSizeLabel ??\n ((size: number) =>\n t('ui.pagination.itemsPerPage.label', '{size} / page', { size }))\n const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)))\n const safePage = Math.min(Math.max(1, page), totalPages)\n const items = React.useMemo(\n () => buildPaginationItems(safePage, totalPages, siblingCount, boundaryCount),\n [safePage, totalPages, siblingCount, boundaryCount],\n )\n const showPageSize = showPageSizeProp ?? Boolean(onPageSizeChange)\n\n const goTo = React.useCallback(\n (next: number) => {\n if (disabled) return\n const bounded = Math.min(Math.max(1, next), totalPages)\n if (bounded !== safePage) onPageChange(bounded)\n },\n [disabled, onPageChange, safePage, totalPages],\n )\n\n return (\n <nav\n ref={ref}\n data-slot=\"pagination\"\n aria-label={props['aria-label'] ?? t('ui.pagination.landmark.ariaLabel', 'Pagination')}\n className={cn('flex w-full flex-wrap items-center justify-between gap-x-6 gap-y-2', className)}\n {...props}\n >\n {showInfo ? (\n <div\n data-slot=\"pagination-info\"\n className=\"shrink-0 text-sm text-muted-foreground tabular-nums\"\n >\n {resolvedFormatPageInfo(safePage, totalPages)}\n </div>\n ) : (\n <div />\n )}\n\n <div\n data-slot=\"pagination-controls\"\n className=\"flex flex-wrap items-center justify-center gap-2\"\n >\n {showFirstLast ? (\n <button\n type=\"button\"\n data-slot=\"pagination-first\"\n aria-label={t('ui.pagination.first.ariaLabel', 'First page')}\n disabled={disabled || safePage <= 1}\n onClick={() => goTo(1)}\n className={cn(navButtonVariants())}\n >\n <ChevronsLeft aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n {showPrevNext ? (\n <button\n type=\"button\"\n data-slot=\"pagination-prev\"\n aria-label={t('ui.pagination.previous.ariaLabel', 'Previous page')}\n disabled={disabled || safePage <= 1}\n onClick={() => goTo(safePage - 1)}\n className={cn(navButtonVariants())}\n >\n <ChevronLeft aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n\n <ol\n data-slot=\"pagination-pages\"\n className=\"flex flex-wrap items-center justify-center gap-2 list-none\"\n >\n {items.map((entry, index) => {\n if (entry === 'ellipsis-left' || entry === 'ellipsis-right') {\n return (\n <li\n key={`${entry}-${index}`}\n data-slot=\"pagination-ellipsis\"\n aria-hidden=\"true\"\n className=\"inline-flex size-8 items-center justify-center text-sm text-muted-foreground\"\n >\n \u2026\n </li>\n )\n }\n const selected = entry === safePage\n return (\n <li key={entry}>\n <button\n type=\"button\"\n data-slot=\"pagination-page\"\n data-state={selected ? 'on' : 'off'}\n aria-current={selected ? 'page' : undefined}\n aria-label={\n selected\n ? t('ui.pagination.page.currentAriaLabel', 'Page {page}, current page', { page: entry })\n : t('ui.pagination.page.goToAriaLabel', 'Go to page {page}', { page: entry })\n }\n disabled={disabled}\n onClick={() => goTo(entry)}\n className={cn(cellVariants({ selected }))}\n >\n {entry}\n </button>\n </li>\n )\n })}\n </ol>\n\n {showPrevNext ? (\n <button\n type=\"button\"\n data-slot=\"pagination-next\"\n aria-label={t('ui.pagination.next.ariaLabel', 'Next page')}\n disabled={disabled || safePage >= totalPages}\n onClick={() => goTo(safePage + 1)}\n className={cn(navButtonVariants())}\n >\n <ChevronRight aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n {showFirstLast ? (\n <button\n type=\"button\"\n data-slot=\"pagination-last\"\n aria-label={t('ui.pagination.last.ariaLabel', 'Last page')}\n disabled={disabled || safePage >= totalPages}\n onClick={() => goTo(totalPages)}\n className={cn(navButtonVariants())}\n >\n <ChevronsRight aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n </div>\n\n {showPageSize && onPageSizeChange ? (\n <div data-slot=\"pagination-page-size\" className=\"shrink-0\">\n <Select\n value={String(pageSize)}\n onValueChange={(next) => onPageSizeChange(Number(next))}\n disabled={disabled}\n >\n <CompactSelectTrigger aria-label={t('ui.pagination.itemsPerPage.ariaLabel', 'Items per page')}>\n <SelectValue />\n </CompactSelectTrigger>\n <SelectContent>\n {pageSizeOptions.map((option) => (\n <SelectItem key={option} value={String(option)}>\n {resolvedFormatPageSizeLabel(option)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n ) : (\n <div />\n )}\n </nav>\n )\n },\n)\nPagination.displayName = 'Pagination'\n\nexport { cellVariants as paginationCellVariants, navButtonVariants as paginationNavVariants }\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport {\n ChevronLeft,\n ChevronRight,\n ChevronsLeft,\n ChevronsRight,\n} from 'lucide-react'\nimport { cva, type VariantProps } from 'class-variance-authority'\n\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n CompactSelectTrigger,\n Select,\n SelectContent,\n SelectItem,\n SelectValue,\n} from './compact-select'\n\n/**\n * Page navigation primitive per Figma `Pagination Group [1.1]` (DS Open\n * Mercato componentSet `199985:4135`).\n *\n * Layout (Figma `Basic` variant):\n *\n * [Left] \"Page 2 of 16\"\n * [Center] \u23EE \u25C0 [1][2]\u2026[N-1][N] \u25B6 \u23ED\n * [Right] \"7 / page\" CompactSelect\n *\n * Figma's three boolean variant props are mapped 1:1 to React props:\n *\n * \uD83E\uDD47 First / Last \u2192 `showFirstLast` (default true)\n * \u23ED\uFE0F Next / Previous \u2192 `showPrevNext` (default true)\n * \uD83E\uDDEA Advanced \u2192 `showInfo` + `showPageSize` (default both true)\n *\n * The numeric page list uses the same ellipsis algorithm Material /\n * MUI / shadcn ship: `boundaryCount` pages at each end (default 1),\n * `siblingCount` pages on either side of the current page (default 1),\n * collapse the rest into `\u2026` placeholders.\n *\n * ```tsx\n * const [page, setPage] = React.useState(1)\n * const [pageSize, setPageSize] = React.useState(25)\n * <Pagination\n * page={page}\n * pageSize={pageSize}\n * total={items.length}\n * onPageChange={setPage}\n * onPageSizeChange={setPageSize}\n * />\n *\n * // Compact (no first/last, no page-size select)\n * <Pagination\n * page={page}\n * pageSize={20}\n * total={120}\n * onPageChange={setPage}\n * showFirstLast={false}\n * showPageSize={false}\n * />\n * ```\n */\n\n/**\n * Build a stable list of pages + ellipsis placeholders that fits a\n * pagination row of `boundaryCount + 2 + siblingCount * 2 + 1` cells.\n * Returns `number` entries (1-indexed) and `'ellipsis-left'` /\n * `'ellipsis-right'` placeholders that the renderer paints as `\u2026`.\n *\n * Exported for tests + consumer reuse (e.g. a server-side rendered\n * pagination indicator that needs the same shape).\n */\nexport function buildPaginationItems(\n page: number,\n totalPages: number,\n siblingCount: number,\n boundaryCount: number,\n): Array<number | 'ellipsis-left' | 'ellipsis-right'> {\n if (totalPages <= 0) return []\n // When everything fits, just list every page.\n const totalSlots = boundaryCount * 2 + siblingCount * 2 + 3\n if (totalPages <= totalSlots) {\n return Array.from({ length: totalPages }, (_, i) => i + 1)\n }\n const startPages = Array.from({ length: boundaryCount }, (_, i) => i + 1)\n const endPages = Array.from(\n { length: boundaryCount },\n (_, i) => totalPages - boundaryCount + 1 + i,\n )\n const siblingStart = Math.max(\n Math.min(page - siblingCount, totalPages - boundaryCount - siblingCount * 2 - 1),\n boundaryCount + 2,\n )\n const siblingEnd = Math.min(\n Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),\n endPages.length > 0 ? endPages[0] - 2 : totalPages - 1,\n )\n const middle: Array<number> = []\n for (let i = siblingStart; i <= siblingEnd; i += 1) middle.push(i)\n\n // Bridge between the start boundary and the sibling window. The\n // ellipsis is only worth showing when \u22652 page numbers fall between\n // them; if exactly 1 page is missing, render that single number\n // instead of \"\u2026\" (cleaner UX \u2014 same width, no information loss).\n const result: Array<number | 'ellipsis-left' | 'ellipsis-right'> = []\n for (const p of startPages) result.push(p)\n if (siblingStart > boundaryCount + 2) {\n result.push('ellipsis-left')\n } else if (siblingStart === boundaryCount + 2) {\n result.push(boundaryCount + 1)\n }\n for (const p of middle) result.push(p)\n const firstEnd = endPages[0] ?? totalPages\n if (siblingEnd < firstEnd - 2) {\n result.push('ellipsis-right')\n } else if (siblingEnd === firstEnd - 2) {\n result.push(firstEnd - 1)\n }\n for (const p of endPages) result.push(p)\n return result\n}\n\nconst cellVariants = cva(\n 'inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-medium outline-none transition-colors tabular-nums ' +\n 'focus-visible:shadow-focus ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n selected: {\n true: 'bg-muted text-foreground',\n false: 'bg-background text-foreground hover:bg-muted/40',\n },\n },\n defaultVariants: { selected: false },\n },\n)\n\nconst navButtonVariants = cva(\n 'inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors ' +\n 'hover:bg-muted/40 hover:text-foreground ' +\n 'focus-visible:shadow-focus ' +\n 'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground',\n)\n\nexport type PaginationProps = React.HTMLAttributes<HTMLDivElement> & {\n /** Current 1-indexed page. */\n page: number\n /** Items per page. */\n pageSize: number\n /** Total item count. Used to derive `Math.ceil(total / pageSize)` pages. */\n total: number\n /**\n * `total` is a floor, not an exact count (a capped list count,\n * `OM_LIST_COUNT_CAP`). The derived page count then only bounds the page\n * *list*: the current page is never clamped down to it, the last-page jump\n * is suppressed (it would present the floor as the end of the data), and\n * Next stays available past the floor when `hasNextPage` says so.\n */\n totalIsCapped?: boolean\n /**\n * Caller-provided \"a next page exists\" signal for the capped case, typically\n * short-page detection (the current page came back full). Ignored when\n * `totalIsCapped` is false; when omitted, Next ends at the known floor.\n */\n hasNextPage?: boolean\n /** Called when the user changes page. */\n onPageChange: (next: number) => void\n /** Called when the user changes page size. Optional \u2014 when omitted,\n * the \"X / page\" select is hidden. */\n onPageSizeChange?: (next: number) => void\n /** Page size options for the select. Default `[10, 25, 50, 100]`. */\n pageSizeOptions?: readonly number[]\n /** Show the left \"Page X of Y\" indicator. Default `true`. */\n showInfo?: boolean\n /** Show the right \"X / page\" select. Default `true` when\n * `onPageSizeChange` is provided; ignored otherwise. */\n showPageSize?: boolean\n /** Show \u23EE / \u23ED first / last buttons. Default `true`. */\n showFirstLast?: boolean\n /** Show \u25C0 / \u25B6 prev / next buttons. Default `true`. */\n showPrevNext?: boolean\n /** Pages on either side of the current page in the page list. Default `1`. */\n siblingCount?: number\n /** Pages pinned at each end of the page list. Default `1`. */\n boundaryCount?: number\n /** Block all interactions. Default `false`. */\n disabled?: boolean\n /** ARIA label for the navigation landmark. Default `\"Pagination\"`. */\n 'aria-label'?: string\n /** Format the \"Page X of Y\" label. */\n formatPageInfo?: (page: number, totalPages: number) => string\n /** Format the \"X / page\" label. */\n formatPageSizeLabel?: (pageSize: number) => string\n}\n\nexport const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(\n (\n {\n className,\n page,\n pageSize,\n total,\n totalIsCapped = false,\n hasNextPage,\n onPageChange,\n onPageSizeChange,\n pageSizeOptions = [10, 25, 50, 100],\n showInfo = true,\n showPageSize: showPageSizeProp,\n showFirstLast = true,\n showPrevNext = true,\n siblingCount = 1,\n boundaryCount = 1,\n disabled = false,\n formatPageInfo,\n formatPageSizeLabel,\n ...props\n },\n ref,\n ) => {\n const t = useT()\n const resolvedFormatPageInfo =\n formatPageInfo ??\n ((p: number, total: number) =>\n totalIsCapped\n ? t('ui.pagination.info.pageOfCapped', 'Page {page} of {total}+', { page: p, total })\n : t('ui.pagination.info.pageOf', 'Page {page} of {total}', { page: p, total }))\n const resolvedFormatPageSizeLabel =\n formatPageSizeLabel ??\n ((size: number) =>\n t('ui.pagination.itemsPerPage.label', '{size} / page', { size }))\n const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)))\n // A capped total is a floor: never clamp the current page down to the\n // derived count \u2014 a page past the floor holds reachable rows.\n const safePage = totalIsCapped\n ? Math.max(1, page)\n : Math.min(Math.max(1, page), totalPages)\n const listPages = Math.max(totalPages, safePage)\n const canGoNext = totalIsCapped\n ? (hasNextPage ?? safePage < listPages)\n : safePage < totalPages\n const items = React.useMemo(\n () => buildPaginationItems(safePage, listPages, siblingCount, boundaryCount),\n [safePage, listPages, siblingCount, boundaryCount],\n )\n const showPageSize = showPageSizeProp ?? Boolean(onPageSizeChange)\n\n const goTo = React.useCallback(\n (next: number) => {\n if (disabled) return\n const upperBound = totalIsCapped ? Math.max(listPages, safePage + 1) : totalPages\n const bounded = Math.min(Math.max(1, next), upperBound)\n if (bounded !== safePage) onPageChange(bounded)\n },\n [disabled, onPageChange, safePage, totalPages, totalIsCapped, listPages],\n )\n\n return (\n <nav\n ref={ref}\n data-slot=\"pagination\"\n aria-label={props['aria-label'] ?? t('ui.pagination.landmark.ariaLabel', 'Pagination')}\n className={cn('flex w-full flex-wrap items-center justify-between gap-x-6 gap-y-2', className)}\n {...props}\n >\n {showInfo ? (\n <div\n data-slot=\"pagination-info\"\n className=\"shrink-0 text-sm text-muted-foreground tabular-nums\"\n >\n {/* When capped, report the best-known floor: a deep page proves at\n least that many pages exist. */}\n {resolvedFormatPageInfo(safePage, totalIsCapped ? listPages : totalPages)}\n </div>\n ) : (\n <div />\n )}\n\n <div\n data-slot=\"pagination-controls\"\n className=\"flex flex-wrap items-center justify-center gap-2\"\n >\n {showFirstLast ? (\n <button\n type=\"button\"\n data-slot=\"pagination-first\"\n aria-label={t('ui.pagination.first.ariaLabel', 'First page')}\n disabled={disabled || safePage <= 1}\n onClick={() => goTo(1)}\n className={cn(navButtonVariants())}\n >\n <ChevronsLeft aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n {showPrevNext ? (\n <button\n type=\"button\"\n data-slot=\"pagination-prev\"\n aria-label={t('ui.pagination.previous.ariaLabel', 'Previous page')}\n disabled={disabled || safePage <= 1}\n onClick={() => goTo(safePage - 1)}\n className={cn(navButtonVariants())}\n >\n <ChevronLeft aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n\n <ol\n data-slot=\"pagination-pages\"\n className=\"flex flex-wrap items-center justify-center gap-2 list-none\"\n >\n {items.map((entry, index) => {\n if (entry === 'ellipsis-left' || entry === 'ellipsis-right') {\n return (\n <li\n key={`${entry}-${index}`}\n data-slot=\"pagination-ellipsis\"\n aria-hidden=\"true\"\n className=\"inline-flex size-8 items-center justify-center text-sm text-muted-foreground\"\n >\n \u2026\n </li>\n )\n }\n const selected = entry === safePage\n return (\n <li key={entry}>\n <button\n type=\"button\"\n data-slot=\"pagination-page\"\n data-state={selected ? 'on' : 'off'}\n aria-current={selected ? 'page' : undefined}\n aria-label={\n selected\n ? t('ui.pagination.page.currentAriaLabel', 'Page {page}, current page', { page: entry })\n : t('ui.pagination.page.goToAriaLabel', 'Go to page {page}', { page: entry })\n }\n disabled={disabled}\n onClick={() => goTo(entry)}\n className={cn(cellVariants({ selected }))}\n >\n {entry}\n </button>\n </li>\n )\n })}\n </ol>\n\n {showPrevNext ? (\n <button\n type=\"button\"\n data-slot=\"pagination-next\"\n aria-label={t('ui.pagination.next.ariaLabel', 'Next page')}\n disabled={disabled || !canGoNext}\n onClick={() => goTo(safePage + 1)}\n className={cn(navButtonVariants())}\n >\n <ChevronRight aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n {/* The last-page jump is suppressed for capped totals: it would land\n on the floor page while presenting itself as the end of the data. */}\n {showFirstLast && !totalIsCapped ? (\n <button\n type=\"button\"\n data-slot=\"pagination-last\"\n aria-label={t('ui.pagination.last.ariaLabel', 'Last page')}\n disabled={disabled || safePage >= totalPages}\n onClick={() => goTo(totalPages)}\n className={cn(navButtonVariants())}\n >\n <ChevronsRight aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n </div>\n\n {showPageSize && onPageSizeChange ? (\n <div data-slot=\"pagination-page-size\" className=\"shrink-0\">\n <Select\n value={String(pageSize)}\n onValueChange={(next) => onPageSizeChange(Number(next))}\n disabled={disabled}\n >\n <CompactSelectTrigger aria-label={t('ui.pagination.itemsPerPage.ariaLabel', 'Items per page')}>\n <SelectValue />\n </CompactSelectTrigger>\n <SelectContent>\n {pageSizeOptions.map((option) => (\n <SelectItem key={option} value={String(option)}>\n {resolvedFormatPageSizeLabel(option)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n ) : (\n <div />\n )}\n </nav>\n )\n },\n)\nPagination.displayName = 'Pagination'\n\nexport { cellVariants as paginationCellVariants, navButtonVariants as paginationNavVariants }\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["total"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.7031.1.005201cd70",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.8-develop.7031.1.005201cd70",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
165
|
+
"@open-mercato/shared": "0.6.8-develop.7031.1.005201cd70",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.0",
|
|
168
168
|
"@testing-library/react": "^16.3.1",
|
|
@@ -128,6 +128,14 @@ export type PaginationProps = {
|
|
|
128
128
|
pageSize: number
|
|
129
129
|
total: number
|
|
130
130
|
totalPages: number
|
|
131
|
+
/**
|
|
132
|
+
* `total` (and the `totalPages` derived from it) is a floor, not an exact
|
|
133
|
+
* count — the server capped the list count (`totalIsCapped: true` on the
|
|
134
|
+
* list payload). Capped totals render as "{total}+" and pagination stays
|
|
135
|
+
* open past the floor via short-page detection instead of ending at
|
|
136
|
+
* `ceil(total / pageSize)`.
|
|
137
|
+
*/
|
|
138
|
+
totalIsCapped?: boolean
|
|
131
139
|
onPageChange: (page: number) => void
|
|
132
140
|
durationMs?: number | null
|
|
133
141
|
cacheStatus?: 'hit' | 'miss' | null
|
|
@@ -2613,8 +2621,21 @@ export function DataTable<T extends RowData>({
|
|
|
2613
2621
|
if (!pagination || pagination.total === 0) return null
|
|
2614
2622
|
|
|
2615
2623
|
const { page, totalPages, onPageChange, durationMs, cacheStatus } = pagination
|
|
2624
|
+
const totalIsCapped = pagination.totalIsCapped === true
|
|
2625
|
+
// Short-page detection: a full current page means a next page may exist,
|
|
2626
|
+
// even past the capped floor. `data` holds exactly the rendered page's rows.
|
|
2627
|
+
const pageIsFull = data.length >= pagination.pageSize
|
|
2616
2628
|
const startItem = (page - 1) * pagination.pageSize + 1
|
|
2617
|
-
|
|
2629
|
+
// Past a capped floor, `total` can sit below the window — derive the end
|
|
2630
|
+
// of the range from the rows actually shown instead.
|
|
2631
|
+
const endItem = totalIsCapped
|
|
2632
|
+
? Math.max(startItem, startItem + data.length - 1)
|
|
2633
|
+
: Math.min(page * pagination.pageSize, pagination.total)
|
|
2634
|
+
// Short-page detection false-positives when the true row count is an exact
|
|
2635
|
+
// multiple of `pageSize`: Next stays enabled on the last full page and the
|
|
2636
|
+
// page after it comes back empty. `total` is the cap rather than 0, so the
|
|
2637
|
+
// pager still renders — claim no range rather than "X to X" over no rows.
|
|
2638
|
+
const pageIsEmpty = data.length === 0
|
|
2618
2639
|
const effectiveDuration = (typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0)
|
|
2619
2640
|
? durationMs
|
|
2620
2641
|
: measuredDurationMs ?? undefined
|
|
@@ -2653,17 +2674,27 @@ export function DataTable<T extends RowData>({
|
|
|
2653
2674
|
page={page}
|
|
2654
2675
|
pageSize={pagination.pageSize}
|
|
2655
2676
|
total={pagination.total}
|
|
2677
|
+
totalIsCapped={totalIsCapped}
|
|
2678
|
+
hasNextPage={totalIsCapped ? pageIsFull : undefined}
|
|
2656
2679
|
onPageChange={(next) => { onPageChange(next); scrollTableIntoView() }}
|
|
2657
2680
|
onPageSizeChange={pagination.onPageSizeChange ? (next) => {
|
|
2658
2681
|
pagination.onPageSizeChange!(next)
|
|
2659
2682
|
scrollTableIntoView()
|
|
2660
2683
|
} : undefined}
|
|
2661
2684
|
pageSizeOptions={pageSizeOptions}
|
|
2662
|
-
formatPageInfo={() =>
|
|
2663
|
-
|
|
2685
|
+
formatPageInfo={() => {
|
|
2686
|
+
if (totalIsCapped) {
|
|
2687
|
+
if (pageIsEmpty) {
|
|
2688
|
+
return t('ui.dataTable.pagination.resultsCappedNoRows', 'No further results past {total}', { total: pagination.total })
|
|
2689
|
+
}
|
|
2690
|
+
return durationLabel
|
|
2691
|
+
? t('ui.dataTable.pagination.resultsCappedWithDuration', 'Showing {start} to {end} of {total}+ results in {duration}', { start: startItem, end: endItem, total: pagination.total, duration: durationLabel })
|
|
2692
|
+
: t('ui.dataTable.pagination.resultsCapped', 'Showing {start} to {end} of {total}+ results', { start: startItem, end: endItem, total: pagination.total })
|
|
2693
|
+
}
|
|
2694
|
+
return durationLabel
|
|
2664
2695
|
? t('ui.dataTable.pagination.resultsWithDuration', 'Showing {start} to {end} of {total} results in {duration}', { start: startItem, end: endItem, total: pagination.total, duration: durationLabel })
|
|
2665
2696
|
: t('ui.dataTable.pagination.results', 'Showing {start} to {end} of {total} results', { start: startItem, end: endItem, total: pagination.total })
|
|
2666
|
-
}
|
|
2697
|
+
}}
|
|
2667
2698
|
formatPageSizeLabel={(size) =>
|
|
2668
2699
|
`${size} ${t('ui.dataTable.pagination.perPage', 'per page')}`
|
|
2669
2700
|
}
|
|
@@ -2672,7 +2703,7 @@ export function DataTable<T extends RowData>({
|
|
|
2672
2703
|
/>
|
|
2673
2704
|
</div>
|
|
2674
2705
|
)
|
|
2675
|
-
}, [pagination, showQueryTime, measuredDurationMs, scrollTableIntoView, t])
|
|
2706
|
+
}, [pagination, data, showQueryTime, measuredDurationMs, scrollTableIntoView, t])
|
|
2676
2707
|
|
|
2677
2708
|
// Auto filters: fetch custom field defs when requested
|
|
2678
2709
|
const resolvedEntityIds = React.useMemo(() => {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { fireEvent, render, screen, within } from '@testing-library/react'
|
|
4
|
+
import { DataTable } from '../DataTable'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
7
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
|
|
9
|
+
jest.mock('next/navigation', () => ({
|
|
10
|
+
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
14
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The capped-count feature's risky half lives in the pager: `Pagination` decides
|
|
19
|
+
* from `totalIsCapped` plus a `hasNextPage` that `DataTable` derives from the
|
|
20
|
+
* rendered row count whether a row past the cap is reachable at all. The unit
|
|
21
|
+
* tests on the primitive pin its own logic; these pin the wiring between a
|
|
22
|
+
* capped list payload and that primitive, which is where a regression would
|
|
23
|
+
* silently strand data while every other test stayed green.
|
|
24
|
+
*
|
|
25
|
+
* Derived from the manual UI QA on #5228: 31 rows behind a reported total of 3.
|
|
26
|
+
*/
|
|
27
|
+
type Row = { id: string; name: string }
|
|
28
|
+
|
|
29
|
+
const columns: ColumnDef<Row>[] = [{ accessorKey: 'name', header: 'Name' }]
|
|
30
|
+
|
|
31
|
+
const PAGE_SIZE = 20
|
|
32
|
+
|
|
33
|
+
function rows(count: number, offset = 0): Row[] {
|
|
34
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
35
|
+
id: `row-${offset + i + 1}`,
|
|
36
|
+
name: `Row ${offset + i + 1}`,
|
|
37
|
+
}))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderTable(pagination: Record<string, unknown>, data: Row[]) {
|
|
41
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0, retry: false } } })
|
|
42
|
+
return render(
|
|
43
|
+
<QueryClientProvider client={queryClient}>
|
|
44
|
+
<I18nProvider locale="en" dict={{}}>
|
|
45
|
+
<DataTable columns={columns as any} data={data} pagination={pagination as any} />
|
|
46
|
+
</I18nProvider>
|
|
47
|
+
</QueryClientProvider>,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const info = () => document.querySelector('[data-slot="pagination-info"]')?.textContent ?? ''
|
|
52
|
+
const next = () => document.querySelector('[data-slot="pagination-next"]') as HTMLButtonElement | null
|
|
53
|
+
const last = () => document.querySelector('[data-slot="pagination-last"]')
|
|
54
|
+
|
|
55
|
+
describe('DataTable pagination with a capped total', () => {
|
|
56
|
+
// The floor must read as a floor. A capped list showing "of 3 results" would
|
|
57
|
+
// state a number the server explicitly refused to vouch for.
|
|
58
|
+
it('renders the total as a floor and hides the jump-to-last control', () => {
|
|
59
|
+
renderTable(
|
|
60
|
+
{ page: 1, pageSize: PAGE_SIZE, total: 3, totalPages: 1, totalIsCapped: true, onPageChange: jest.fn() },
|
|
61
|
+
rows(PAGE_SIZE),
|
|
62
|
+
)
|
|
63
|
+
expect(info()).toBe('Showing 1 to 20 of 3+ results')
|
|
64
|
+
// The jump would land on the floor's last page while presenting itself as
|
|
65
|
+
// the end of the data, which is precisely the lie this feature must avoid.
|
|
66
|
+
expect(last()).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// This is the assertion that would have caught an unfixed clamp: with a
|
|
70
|
+
// floor-derived page count of 1, a naive pager disables Next on page 1 and
|
|
71
|
+
// every row past the cap becomes unreachable.
|
|
72
|
+
it('keeps Next live past the floor while the page comes back full', () => {
|
|
73
|
+
const onPageChange = jest.fn()
|
|
74
|
+
renderTable(
|
|
75
|
+
{ page: 1, pageSize: PAGE_SIZE, total: 3, totalPages: 1, totalIsCapped: true, onPageChange },
|
|
76
|
+
rows(PAGE_SIZE),
|
|
77
|
+
)
|
|
78
|
+
const button = next()
|
|
79
|
+
expect(button?.disabled).toBe(false)
|
|
80
|
+
fireEvent.click(button!)
|
|
81
|
+
expect(onPageChange).toHaveBeenCalledWith(2)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Deep-linking past the floor must not bounce the user back, and the range
|
|
85
|
+
// label has to describe the rows actually on screen rather than the floor.
|
|
86
|
+
it('serves a page past the floor without snapping back, and retires Next on a short page', () => {
|
|
87
|
+
const onPageChange = jest.fn()
|
|
88
|
+
renderTable(
|
|
89
|
+
{ page: 2, pageSize: PAGE_SIZE, total: 3, totalPages: 1, totalIsCapped: true, onPageChange },
|
|
90
|
+
rows(11, PAGE_SIZE),
|
|
91
|
+
)
|
|
92
|
+
expect(info()).toBe('Showing 21 to 31 of 3+ results')
|
|
93
|
+
expect(within(document.body).getByText('Row 31')).toBeInTheDocument()
|
|
94
|
+
// Short page ⇒ nothing beyond it, so Next retires here rather than at the floor.
|
|
95
|
+
expect(next()?.disabled).toBe(true)
|
|
96
|
+
expect(onPageChange).not.toHaveBeenCalled()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// Capping is conditional: below the cap the server sends no flag, and the
|
|
100
|
+
// table must be byte-identical to its pre-feature behaviour.
|
|
101
|
+
it('leaves an uncapped list unchanged — exact total, jump-to-last offered', () => {
|
|
102
|
+
renderTable(
|
|
103
|
+
{ page: 1, pageSize: PAGE_SIZE, total: 31, totalPages: 2, onPageChange: jest.fn() },
|
|
104
|
+
rows(PAGE_SIZE),
|
|
105
|
+
)
|
|
106
|
+
expect(info()).toBe('Showing 1 to 20 of 31 results')
|
|
107
|
+
expect(last()).not.toBeNull()
|
|
108
|
+
expect(next()?.disabled).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// The exact-multiple false positive: short-page detection cannot distinguish
|
|
112
|
+
// "a full last page" from "more to come", so Next survives one page too far.
|
|
113
|
+
// The label must not then claim a row it is not showing.
|
|
114
|
+
it('claims no range when a capped page comes back empty', () => {
|
|
115
|
+
renderTable(
|
|
116
|
+
{ page: 3, pageSize: PAGE_SIZE, total: 3, totalPages: 1, totalIsCapped: true, onPageChange: jest.fn() },
|
|
117
|
+
[],
|
|
118
|
+
)
|
|
119
|
+
expect(info()).toBe('No further results past 3')
|
|
120
|
+
expect(next()?.disabled).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -6,6 +6,8 @@ export type ListResponse<T> = {
|
|
|
6
6
|
page: number
|
|
7
7
|
pageSize: number
|
|
8
8
|
totalPages: number
|
|
9
|
+
/** Present (true) when the server capped the count: `total`/`totalPages` are floors. */
|
|
10
|
+
totalIsCapped?: boolean
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export type CrudExportFormat = 'csv' | 'json' | 'xml' | 'markdown'
|
|
@@ -297,3 +297,57 @@ describe('Pagination', () => {
|
|
|
297
297
|
expect(ref.current?.getAttribute('data-slot')).toBe('pagination')
|
|
298
298
|
})
|
|
299
299
|
})
|
|
300
|
+
|
|
301
|
+
describe('Pagination with a capped total (totalIsCapped)', () => {
|
|
302
|
+
// 100 rows / pageSize 10 → a floor of 10 pages; the real set is larger.
|
|
303
|
+
const capped = { page: 1, pageSize: 10, total: 100, totalIsCapped: true, onPageChange: () => {} }
|
|
304
|
+
|
|
305
|
+
it('renders the capped page info with a trailing plus', () => {
|
|
306
|
+
const { container } = render(<Pagination {...capped} />)
|
|
307
|
+
const info = container.querySelector('[data-slot="pagination-info"]')
|
|
308
|
+
expect(info?.textContent).toBe('Page 1 of 10+')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('suppresses the last-page jump — it would present the floor as the end of the data', () => {
|
|
312
|
+
const { container } = render(<Pagination {...capped} />)
|
|
313
|
+
expect(container.querySelector('[data-slot="pagination-last"]')).toBeNull()
|
|
314
|
+
expect(container.querySelector('[data-slot="pagination-first"]')).not.toBeNull()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('keeps Next enabled past the floor while hasNextPage is true', () => {
|
|
318
|
+
const onPageChange = jest.fn()
|
|
319
|
+
const { container } = render(
|
|
320
|
+
<Pagination {...capped} page={10} hasNextPage onPageChange={onPageChange} />,
|
|
321
|
+
)
|
|
322
|
+
const next = container.querySelector('[data-slot="pagination-next"]') as HTMLButtonElement
|
|
323
|
+
expect(next.disabled).toBe(false)
|
|
324
|
+
fireEvent.click(next)
|
|
325
|
+
expect(onPageChange).toHaveBeenCalledWith(11)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('disables Next at the floor when hasNextPage reports a short page', () => {
|
|
329
|
+
const { container } = render(<Pagination {...capped} page={10} hasNextPage={false} />)
|
|
330
|
+
const next = container.querySelector('[data-slot="pagination-next"]') as HTMLButtonElement
|
|
331
|
+
expect(next.disabled).toBe(true)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('never clamps a deep-linked page down to the floor', () => {
|
|
335
|
+
const { container } = render(<Pagination {...capped} page={37} hasNextPage />)
|
|
336
|
+
const current = container.querySelector('[data-slot="pagination-page"][data-state="on"]')
|
|
337
|
+
expect(current?.textContent).toBe('37')
|
|
338
|
+
const info = container.querySelector('[data-slot="pagination-info"]')
|
|
339
|
+
expect(info?.textContent).toBe('Page 37 of 37+')
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('keeps exact-total behavior byte-identical when the flag is absent', () => {
|
|
343
|
+
const onPageChange = jest.fn()
|
|
344
|
+
const { container } = render(
|
|
345
|
+
<Pagination page={10} pageSize={10} total={100} onPageChange={onPageChange} />,
|
|
346
|
+
)
|
|
347
|
+
const next = container.querySelector('[data-slot="pagination-next"]') as HTMLButtonElement
|
|
348
|
+
expect(next.disabled).toBe(true)
|
|
349
|
+
expect(container.querySelector('[data-slot="pagination-last"]')).not.toBeNull()
|
|
350
|
+
const info = container.querySelector('[data-slot="pagination-info"]')
|
|
351
|
+
expect(info?.textContent).toBe('Page 10 of 10')
|
|
352
|
+
})
|
|
353
|
+
})
|
|
@@ -151,6 +151,20 @@ export type PaginationProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
|
151
151
|
pageSize: number
|
|
152
152
|
/** Total item count. Used to derive `Math.ceil(total / pageSize)` pages. */
|
|
153
153
|
total: number
|
|
154
|
+
/**
|
|
155
|
+
* `total` is a floor, not an exact count (a capped list count,
|
|
156
|
+
* `OM_LIST_COUNT_CAP`). The derived page count then only bounds the page
|
|
157
|
+
* *list*: the current page is never clamped down to it, the last-page jump
|
|
158
|
+
* is suppressed (it would present the floor as the end of the data), and
|
|
159
|
+
* Next stays available past the floor when `hasNextPage` says so.
|
|
160
|
+
*/
|
|
161
|
+
totalIsCapped?: boolean
|
|
162
|
+
/**
|
|
163
|
+
* Caller-provided "a next page exists" signal for the capped case, typically
|
|
164
|
+
* short-page detection (the current page came back full). Ignored when
|
|
165
|
+
* `totalIsCapped` is false; when omitted, Next ends at the known floor.
|
|
166
|
+
*/
|
|
167
|
+
hasNextPage?: boolean
|
|
154
168
|
/** Called when the user changes page. */
|
|
155
169
|
onPageChange: (next: number) => void
|
|
156
170
|
/** Called when the user changes page size. Optional — when omitted,
|
|
@@ -188,6 +202,8 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
|
|
|
188
202
|
page,
|
|
189
203
|
pageSize,
|
|
190
204
|
total,
|
|
205
|
+
totalIsCapped = false,
|
|
206
|
+
hasNextPage,
|
|
191
207
|
onPageChange,
|
|
192
208
|
onPageSizeChange,
|
|
193
209
|
pageSizeOptions = [10, 25, 50, 100],
|
|
@@ -208,26 +224,37 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
|
|
|
208
224
|
const resolvedFormatPageInfo =
|
|
209
225
|
formatPageInfo ??
|
|
210
226
|
((p: number, total: number) =>
|
|
211
|
-
|
|
227
|
+
totalIsCapped
|
|
228
|
+
? t('ui.pagination.info.pageOfCapped', 'Page {page} of {total}+', { page: p, total })
|
|
229
|
+
: t('ui.pagination.info.pageOf', 'Page {page} of {total}', { page: p, total }))
|
|
212
230
|
const resolvedFormatPageSizeLabel =
|
|
213
231
|
formatPageSizeLabel ??
|
|
214
232
|
((size: number) =>
|
|
215
233
|
t('ui.pagination.itemsPerPage.label', '{size} / page', { size }))
|
|
216
234
|
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)))
|
|
217
|
-
|
|
235
|
+
// A capped total is a floor: never clamp the current page down to the
|
|
236
|
+
// derived count — a page past the floor holds reachable rows.
|
|
237
|
+
const safePage = totalIsCapped
|
|
238
|
+
? Math.max(1, page)
|
|
239
|
+
: Math.min(Math.max(1, page), totalPages)
|
|
240
|
+
const listPages = Math.max(totalPages, safePage)
|
|
241
|
+
const canGoNext = totalIsCapped
|
|
242
|
+
? (hasNextPage ?? safePage < listPages)
|
|
243
|
+
: safePage < totalPages
|
|
218
244
|
const items = React.useMemo(
|
|
219
|
-
() => buildPaginationItems(safePage,
|
|
220
|
-
[safePage,
|
|
245
|
+
() => buildPaginationItems(safePage, listPages, siblingCount, boundaryCount),
|
|
246
|
+
[safePage, listPages, siblingCount, boundaryCount],
|
|
221
247
|
)
|
|
222
248
|
const showPageSize = showPageSizeProp ?? Boolean(onPageSizeChange)
|
|
223
249
|
|
|
224
250
|
const goTo = React.useCallback(
|
|
225
251
|
(next: number) => {
|
|
226
252
|
if (disabled) return
|
|
227
|
-
const
|
|
253
|
+
const upperBound = totalIsCapped ? Math.max(listPages, safePage + 1) : totalPages
|
|
254
|
+
const bounded = Math.min(Math.max(1, next), upperBound)
|
|
228
255
|
if (bounded !== safePage) onPageChange(bounded)
|
|
229
256
|
},
|
|
230
|
-
[disabled, onPageChange, safePage, totalPages],
|
|
257
|
+
[disabled, onPageChange, safePage, totalPages, totalIsCapped, listPages],
|
|
231
258
|
)
|
|
232
259
|
|
|
233
260
|
return (
|
|
@@ -243,7 +270,9 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
|
|
|
243
270
|
data-slot="pagination-info"
|
|
244
271
|
className="shrink-0 text-sm text-muted-foreground tabular-nums"
|
|
245
272
|
>
|
|
246
|
-
{
|
|
273
|
+
{/* When capped, report the best-known floor: a deep page proves at
|
|
274
|
+
least that many pages exist. */}
|
|
275
|
+
{resolvedFormatPageInfo(safePage, totalIsCapped ? listPages : totalPages)}
|
|
247
276
|
</div>
|
|
248
277
|
) : (
|
|
249
278
|
<div />
|
|
@@ -324,14 +353,16 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
|
|
|
324
353
|
type="button"
|
|
325
354
|
data-slot="pagination-next"
|
|
326
355
|
aria-label={t('ui.pagination.next.ariaLabel', 'Next page')}
|
|
327
|
-
disabled={disabled ||
|
|
356
|
+
disabled={disabled || !canGoNext}
|
|
328
357
|
onClick={() => goTo(safePage + 1)}
|
|
329
358
|
className={cn(navButtonVariants())}
|
|
330
359
|
>
|
|
331
360
|
<ChevronRight aria-hidden="true" className="size-5" />
|
|
332
361
|
</button>
|
|
333
362
|
) : null}
|
|
334
|
-
{
|
|
363
|
+
{/* The last-page jump is suppressed for capped totals: it would land
|
|
364
|
+
on the floor page while presenting itself as the end of the data. */}
|
|
365
|
+
{showFirstLast && !totalIsCapped ? (
|
|
335
366
|
<button
|
|
336
367
|
type="button"
|
|
337
368
|
data-slot="pagination-last"
|