@dnax/ui 0.0.75 → 0.0.76

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/index.ts CHANGED
@@ -209,6 +209,20 @@ export type { TextareaMetrics, TextareaSizing, TextareaComputedStyle } from "./l
209
209
  export { useTextareaAutosize } from "./lib/useTextareaAutosize"
210
210
  export type { TextareaAutosize, UseTextareaAutosizeOptions } from "./lib/useTextareaAutosize"
211
211
  export { iconsRegistered, registerIconSet, registerIcons } from "./lib/registerIcons"
212
+ export { pageInfo } from "./lib/pagination"
213
+ export type { PageInfo, PageInfoOptions } from "./lib/pagination"
214
+ export { CELL_SIZE_MAX, CELL_SIZE_MIN, CELL_SIZE_PX, cellFontFor, cellSizePx, cellWidthFor } from "./lib/cellSize"
215
+ export type { CellSizePreset } from "./lib/cellSize"
216
+ export { acceptsFile, attachmentsOf, fileIconKind, formatFileSize, isAttachment, selectFiles } from "./lib/fileList"
217
+ export type {
218
+ AttachmentLike,
219
+ FileDescriptor,
220
+ FileIconKind,
221
+ FileRejection,
222
+ FileRejectionReason,
223
+ FileSelection,
224
+ FileSelectionOptions,
225
+ } from "./lib/fileList"
212
226
  export { lucideIconSet } from "./lib/iconSet"
213
227
  export { icons } from "./lib/icons"
214
228
  export type { IconSetJson } from "./lib/icons"
@@ -0,0 +1,52 @@
1
+ // Taille de cellule : presets, bornes, dérivés (largeur, police).
2
+ // Le point sensible est la **non-régression** : `md` doit reproduire exactement les valeurs
3
+ // historiques (32 px de haut, 160 px de large, 13 px de police).
4
+ import { describe, expect, it } from "bun:test"
5
+ import { CELL_SIZE_MAX, CELL_SIZE_MIN, CELL_SIZE_PX, cellFontFor, cellSizePx, cellWidthFor } from "./cellSize"
6
+
7
+ describe("cellSizePx", () => {
8
+ it("md est le défaut historique (32 px)", () => {
9
+ expect(cellSizePx()).toBe(32)
10
+ expect(cellSizePx(null)).toBe(32)
11
+ expect(cellSizePx("md")).toBe(32)
12
+ expect(CELL_SIZE_PX.md).toBe(32)
13
+ })
14
+
15
+ it("résout les presets", () => {
16
+ expect(cellSizePx("sm")).toBe(28)
17
+ expect(cellSizePx("lg")).toBe(40)
18
+ })
19
+
20
+ it("accepte une valeur en px, arrondie et bornée", () => {
21
+ expect(cellSizePx(48)).toBe(48)
22
+ expect(cellSizePx(66)).toBe(66)
23
+ expect(cellSizePx(47.6)).toBe(48)
24
+ expect(cellSizePx(2)).toBe(CELL_SIZE_MIN)
25
+ expect(cellSizePx(999)).toBe(CELL_SIZE_MAX)
26
+ expect(cellSizePx(Number.NaN)).toBe(32)
27
+ })
28
+ })
29
+
30
+ describe("cellWidthFor", () => {
31
+ it("garde le ratio historique 5:1", () => {
32
+ expect(cellWidthFor(32)).toBe(160)
33
+ expect(cellWidthFor(28)).toBe(140)
34
+ expect(cellWidthFor(40)).toBe(200)
35
+ expect(cellWidthFor(48)).toBe(240)
36
+ expect(cellWidthFor(66)).toBe(330)
37
+ })
38
+ })
39
+
40
+ describe("cellFontFor", () => {
41
+ it("md garde la police historique (13 px)", () => {
42
+ expect(cellFontFor(32)).toBe(13)
43
+ })
44
+
45
+ it("grandit avec la cellule puis plafonne", () => {
46
+ expect(cellFontFor(28)).toBe(11)
47
+ expect(cellFontFor(40)).toBe(16)
48
+ expect(cellFontFor(48)).toBe(19)
49
+ expect(cellFontFor(66)).toBe(20)
50
+ expect(cellFontFor(120)).toBe(20)
51
+ })
52
+ })
@@ -0,0 +1,43 @@
1
+ // Taille de cellule du tableur — calculs **purs** (aucun DOM).
2
+ //
3
+ // Une seule échelle pilote la hauteur de ligne, la largeur de colonne par défaut **et** la
4
+ // taille de police des cellules, pour qu'une grille « large » reste proportionnée (une
5
+ // cellule de 66px avec une police de 13px paraît vide).
6
+
7
+ /** Presets de hauteur de cellule, en px. */
8
+ export type CellSizePreset = "sm" | "md" | "lg"
9
+
10
+ export const CELL_SIZE_PX: Record<CellSizePreset, number> = {
11
+ sm: 28,
12
+ md: 32,
13
+ lg: 40,
14
+ }
15
+
16
+ /** Bornes d'une hauteur passée en nombre (px). */
17
+ export const CELL_SIZE_MIN = 20
18
+ export const CELL_SIZE_MAX = 120
19
+
20
+ /**
21
+ * Hauteur de cellule en px : preset (`sm` 28, `md` 32, `lg` 40), nombre borné
22
+ * `[20, 120]`, ou `md` par défaut / pour une valeur invalide.
23
+ */
24
+ export const cellSizePx = (size?: CellSizePreset | number | null): number => {
25
+ if (typeof size === "number" && Number.isFinite(size)) {
26
+ return Math.min(CELL_SIZE_MAX, Math.max(CELL_SIZE_MIN, Math.round(size)))
27
+ }
28
+ if (size === "sm" || size === "md" || size === "lg") return CELL_SIZE_PX[size]
29
+ return CELL_SIZE_PX.md
30
+ }
31
+
32
+ /**
33
+ * Largeur de colonne par défaut dérivée de la hauteur — ratio **5:1**, cohérent avec le
34
+ * couple historique 32 px / 160 px. Une cellule haute est aussi plus large.
35
+ */
36
+ export const cellWidthFor = (heightPx: number): number => Math.round(heightPx * 5)
37
+
38
+ /**
39
+ * Taille de police des cellules : suit la hauteur (~40 %), bornée pour rester lisible.
40
+ * `32 px → 13` (la valeur historique), `40 → 16`, `48 → 20`, `66 → 20` (plafond).
41
+ */
42
+ export const cellFontFor = (heightPx: number): number =>
43
+ Math.min(20, Math.max(11, Math.round(heightPx * 0.406)))
@@ -0,0 +1,119 @@
1
+ // Fichiers joints : règles d'`accept`, quota, icônes — testées sans navigateur ni DOM.
2
+ // Ces helpers sont la seule source de vérité (QFilePicker et QSpreadsheet `attachment`).
3
+ import { describe, expect, it } from "bun:test"
4
+ import {
5
+ acceptsFile,
6
+ attachmentsOf,
7
+ fileIconKind,
8
+ formatFileSize,
9
+ selectFiles,
10
+ type FileDescriptor,
11
+ } from "./fileList"
12
+
13
+ const file = (name: string, type = "", size = 1000): File =>
14
+ new File([new Uint8Array(size)], name, { type })
15
+
16
+ const desc = (name: string, type = ""): FileDescriptor => ({ name, type, size: 0 })
17
+
18
+ describe("formatFileSize", () => {
19
+ it("choisit l'unité lisible", () => {
20
+ expect(formatFileSize(0)).toBe("0 o")
21
+ expect(formatFileSize(512)).toBe("512 o")
22
+ expect(formatFileSize(2048)).toBe("2 Ko")
23
+ expect(formatFileSize(5 * 1024 * 1024)).toBe("5 Mo")
24
+ expect(formatFileSize(3 * 1024 * 1024 * 1024)).toBe("3.0 Go")
25
+ })
26
+
27
+ it("neutralise les valeurs invalides", () => {
28
+ expect(formatFileSize(Number.NaN)).toBe("0 o")
29
+ expect(formatFileSize(-12)).toBe("0 o")
30
+ })
31
+ })
32
+
33
+ describe("acceptsFile", () => {
34
+ it("accepte tout sans motif", () => {
35
+ expect(acceptsFile(desc("a.bin"), "")).toBe(true)
36
+ expect(acceptsFile(desc("a.bin"), undefined)).toBe(true)
37
+ expect(acceptsFile(desc("a.bin"), " , ")).toBe(true)
38
+ })
39
+
40
+ it("gère les jokers de type MIME", () => {
41
+ expect(acceptsFile(desc("photo.png", "image/png"), "image/*")).toBe(true)
42
+ expect(acceptsFile(desc("doc.pdf", "application/pdf"), "image/*")).toBe(false)
43
+ expect(acceptsFile(desc("clip.mp4", "video/mp4"), "image/*, video/*")).toBe(true)
44
+ })
45
+
46
+ it("gère les extensions et les types complets", () => {
47
+ expect(acceptsFile(desc("rapport.PDF"), ".pdf")).toBe(true)
48
+ expect(acceptsFile(desc("rapport.txt"), ".pdf")).toBe(false)
49
+ expect(acceptsFile(desc("a.bin", "Application/PDF"), "application/pdf")).toBe(true)
50
+ })
51
+
52
+ it("ignore un motif invalide (ni MIME, ni extension)", () => {
53
+ expect(acceptsFile(desc("a.png", "image/png"), "image")).toBe(false)
54
+ })
55
+ })
56
+
57
+ describe("fileIconKind", () => {
58
+ it("classe par type MIME puis par extension", () => {
59
+ expect(fileIconKind(desc("a.png", "image/png"))).toBe("image")
60
+ expect(fileIconKind(desc("a.mp4", "video/mp4"))).toBe("video")
61
+ expect(fileIconKind(desc("a.mp3", "audio/mpeg"))).toBe("audio")
62
+ expect(fileIconKind(desc("a.zip", "application/zip"))).toBe("archive")
63
+ expect(fileIconKind(desc("a.xlsx"))).toBe("spreadsheet")
64
+ expect(fileIconKind(desc("a.ts"))).toBe("code")
65
+ expect(fileIconKind(desc("a.bin"))).toBe("file")
66
+ })
67
+ })
68
+
69
+ describe("selectFiles", () => {
70
+ it("rejette par type, taille puis quota (dans cet ordre)", () => {
71
+ const result = selectFiles(
72
+ [file("a.png", "image/png"), file("b.pdf", "application/pdf"), file("c.png", "image/png", 5000)],
73
+ { accept: "image/*", maxSize: 2000, maxFiles: 1 },
74
+ )
75
+ expect(result.accepted.map((f) => f.name)).toEqual(["a.png"])
76
+ expect(result.rejected.map((r) => r.reason)).toEqual(["type", "size"])
77
+ })
78
+
79
+ it("tient compte des fichiers déjà présents dans le quota", () => {
80
+ const result = selectFiles([file("a.png"), file("b.png")], { maxFiles: 3, current: 2 })
81
+ expect(result.accepted.map((f) => f.name)).toEqual(["a.png"])
82
+ expect(result.rejected.map((r) => r.reason)).toEqual(["count"])
83
+ })
84
+
85
+ it("sans quota, tout passe (ordre conservé)", () => {
86
+ const result = selectFiles([file("a.png"), file("b.png")])
87
+ expect(result.accepted.map((f) => f.name)).toEqual(["a.png", "b.png"])
88
+ expect(result.rejected).toEqual([])
89
+ })
90
+
91
+ it("quota déjà atteint → tout est rejeté en `count`", () => {
92
+ const result = selectFiles([file("a.png")], { maxFiles: 2, current: 2 })
93
+ expect(result.accepted).toEqual([])
94
+ expect(result.rejected.map((r) => r.reason)).toEqual(["count"])
95
+ })
96
+ })
97
+
98
+ describe("attachmentsOf", () => {
99
+ it("normalise un tableau et ignore les entrées invalides", () => {
100
+ const list = attachmentsOf([
101
+ { id: "a", name: "a.png", size: 1, type: "image/png" },
102
+ { body: "pas un fichier" },
103
+ null,
104
+ { name: "b.pdf" },
105
+ ])
106
+ expect(list.map((att) => att.name)).toEqual(["a.png", "b.pdf"])
107
+ })
108
+
109
+ it("accepte un fichier seul (donnée posée à la main)", () => {
110
+ expect(attachmentsOf({ name: "seul.pdf" }).map((att) => att.name)).toEqual(["seul.pdf"])
111
+ })
112
+
113
+ it("renvoie un tableau vide pour le vide ou une chaîne", () => {
114
+ expect(attachmentsOf(null)).toEqual([])
115
+ expect(attachmentsOf("")).toEqual([])
116
+ expect(attachmentsOf([])).toEqual([])
117
+ expect(attachmentsOf("a.png")).toEqual([])
118
+ })
119
+ })
@@ -0,0 +1,159 @@
1
+ // Fichiers joints — helpers **purs** partagés par `QFilePicker` et `QSpreadsheet`
2
+ // (type de cellule `attachment`). Aucun DOM : les règles `accept` sont exactement celles
3
+ // de l'attribut HTML (`image/*`, `application/pdf`, `.pdf`…) pour que le filtre du
4
+ // sélecteur et la validation programmatique disent la même chose.
5
+
6
+ /** Raison d'un rejet de sélection. */
7
+ export type FileRejectionReason = "type" | "size" | "count"
8
+
9
+ export interface FileRejection {
10
+ file: File
11
+ reason: FileRejectionReason
12
+ }
13
+
14
+ /** Ce dont les helpers ont besoin — un vrai `File` y satisfait. */
15
+ export interface FileDescriptor {
16
+ name: string
17
+ type: string
18
+ size: number
19
+ }
20
+
21
+ const ARCHIVE_EXT = /\.(zip|rar|7z|tar|gz|bz2|xz|tgz)$/
22
+ const SPREADSHEET_EXT = /\.(xlsx|xls|csv|ods|tsv)$/
23
+ const CODE_EXT =
24
+ /\.(json|js|mjs|cjs|ts|jsx|tsx|html?|xml|css|scss|sass|less|vue|py|java|c|cpp|h|hpp|sh|bash|yml|yaml|toml|ini|go|rs|php|rb|swift|kt)$/
25
+
26
+ /** Famille d'icône d'un fichier (le composant la mappe sur `icons.*`). */
27
+ export type FileIconKind =
28
+ | "image"
29
+ | "video"
30
+ | "audio"
31
+ | "archive"
32
+ | "spreadsheet"
33
+ | "code"
34
+ | "file"
35
+
36
+ export const fileIconKind = (file: Pick<FileDescriptor, "name" | "type">): FileIconKind => {
37
+ const type = (file.type ?? "").toLowerCase()
38
+ const name = (file.name ?? "").toLowerCase()
39
+ if (type.startsWith("video/")) return "video"
40
+ if (type.startsWith("audio/")) return "audio"
41
+ if (ARCHIVE_EXT.test(name) || /zip|compressed/.test(type)) return "archive"
42
+ if (SPREADSHEET_EXT.test(name) || type.includes("spreadsheet") || type.includes("excel")) {
43
+ return "spreadsheet"
44
+ }
45
+ if (CODE_EXT.test(name)) return "code"
46
+ if (type.startsWith("image/")) return "image"
47
+ return "file"
48
+ }
49
+
50
+ /** Taille lisible : `o` / `Ko` / `Mo` / `Go`. */
51
+ export const formatFileSize = (bytes: number): string => {
52
+ if (!Number.isFinite(bytes) || bytes < 0) return "0 o"
53
+ if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`
54
+ if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} Mo`
55
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} Ko`
56
+ return `${bytes} o`
57
+ }
58
+
59
+ /**
60
+ * Le fichier correspond-il à `accept` ? Mêmes règles que l'attribut HTML : liste
61
+ * séparée par des virgules, `type/*`, type MIME complet ou extension (`.pdf`).
62
+ * `accept` vide/absent → tout passe.
63
+ */
64
+ export const acceptsFile = (
65
+ file: Pick<FileDescriptor, "name" | "type">,
66
+ accept?: string,
67
+ ): boolean => {
68
+ const patterns = (accept ?? "")
69
+ .split(",")
70
+ .map((pattern) => pattern.trim().toLowerCase())
71
+ .filter(Boolean)
72
+ if (patterns.length === 0) return true
73
+
74
+ const name = (file.name ?? "").toLowerCase()
75
+ const type = (file.type ?? "").toLowerCase()
76
+
77
+ return patterns.some((pattern) => {
78
+ // `image/*`, `video/*`… (et `application/*` par généralisation)
79
+ if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1))
80
+ if (pattern.startsWith(".")) return name.endsWith(pattern)
81
+ if (pattern.includes("/")) return type === pattern
82
+ return false
83
+ })
84
+ }
85
+
86
+ export interface FileSelectionOptions {
87
+ /** Motif `accept` de la colonne (voir `acceptsFile`) */
88
+ accept?: string
89
+ /** Taille maximale par fichier, en octets (0/absent = illimité) */
90
+ maxSize?: number
91
+ /** Nombre maximum de fichiers au total (0/absent = illimité) */
92
+ maxFiles?: number
93
+ /** Fichiers déjà présents (réduit le quota restant) */
94
+ current?: number
95
+ }
96
+
97
+ export interface FileSelection {
98
+ accepted: File[]
99
+ rejected: FileRejection[]
100
+ }
101
+
102
+ /**
103
+ * Filtre une sélection : par type, par taille, puis par quota — dans cet ordre, pour que
104
+ * le motif de rejet soit le plus précis possible. L'ordre des fichiers acceptés est celui
105
+ * de `incoming`.
106
+ */
107
+ export const selectFiles = (
108
+ incoming: File[],
109
+ options: FileSelectionOptions = {},
110
+ ): FileSelection => {
111
+ const accepted: File[] = []
112
+ const rejected: FileRejection[] = []
113
+
114
+ const valid: File[] = []
115
+ for (const file of incoming) {
116
+ if (options.maxSize && options.maxSize > 0 && file.size > options.maxSize) {
117
+ rejected.push({ file, reason: "size" })
118
+ continue
119
+ }
120
+ if (!acceptsFile(file, options.accept)) {
121
+ rejected.push({ file, reason: "type" })
122
+ continue
123
+ }
124
+ valid.push(file)
125
+ }
126
+
127
+ const quota = options.maxFiles && options.maxFiles > 0 ? options.maxFiles : Number.POSITIVE_INFINITY
128
+ const remaining = Math.max(0, quota - Math.max(0, options.current ?? 0))
129
+ for (const [index, file] of valid.entries()) {
130
+ if (index < remaining) accepted.push(file)
131
+ else rejected.push({ file, reason: "count" })
132
+ }
133
+
134
+ return { accepted, rejected }
135
+ }
136
+
137
+ // ─── Fichiers stockés dans une cellule (`attachment`) ───
138
+
139
+ /** Forme minimale d'un fichier stocké (ce qu'une cellule `attachment` contient). */
140
+ export interface AttachmentLike {
141
+ id?: string
142
+ name: string
143
+ size?: number
144
+ type?: string
145
+ url?: string
146
+ }
147
+
148
+ /** L'entrée ressemble-t-elle à un fichier stocké ? (tolérance des données importées) */
149
+ export const isAttachment = (value: unknown): value is AttachmentLike =>
150
+ !!value && typeof value === "object" && typeof (value as { name?: unknown }).name === "string"
151
+
152
+ /**
153
+ * Valeur d'une cellule `attachment` normalisée en tableau : un fichier **seul** est accepté
154
+ * (donnée posée à la main ou importée), tout le reste est ignoré.
155
+ */
156
+ export const attachmentsOf = (value: unknown): AttachmentLike[] => {
157
+ if (Array.isArray(value)) return value.filter(isAttachment)
158
+ return isAttachment(value) ? [value] : []
159
+ }
@@ -0,0 +1,77 @@
1
+ // Pagination distante : bornes, nombre de pages, plage globale des lignes.
2
+ // Les cas qui cassent en pratique : total qui rétrécit (page hors bornes), dernière page
3
+ // partielle, `pageSize` désactivé, total nul.
4
+ import { describe, expect, it } from "bun:test"
5
+ import { pageInfo } from "./pagination"
6
+
7
+ describe("pageInfo", () => {
8
+ it("calcule la première page", () => {
9
+ expect(pageInfo({ page: 1, pageSize: 400, total: 1200 })).toEqual({
10
+ page: 1,
11
+ pageCount: 3,
12
+ offset: 0,
13
+ from: 1,
14
+ to: 400,
15
+ })
16
+ })
17
+
18
+ it("décale la plage sur les pages suivantes", () => {
19
+ expect(pageInfo({ page: 3, pageSize: 400, total: 1200 })).toEqual({
20
+ page: 3,
21
+ pageCount: 3,
22
+ offset: 800,
23
+ from: 801,
24
+ to: 1200,
25
+ })
26
+ })
27
+
28
+ it("gère une dernière page partielle", () => {
29
+ expect(pageInfo({ page: 2, pageSize: 400, total: 500 })).toEqual({
30
+ page: 2,
31
+ pageCount: 2,
32
+ offset: 400,
33
+ from: 401,
34
+ to: 500,
35
+ })
36
+ })
37
+
38
+ it("tient compte des lignes réellement chargées", () => {
39
+ // La page 2 n'a reçu que 80 lignes (chargement partiel) : la plage le reflète
40
+ expect(pageInfo({ page: 2, pageSize: 400, total: 1200, loaded: 80 })).toMatchObject({
41
+ from: 401,
42
+ to: 480,
43
+ })
44
+ })
45
+
46
+ it("borne une page trop haute ou trop basse", () => {
47
+ expect(pageInfo({ page: 99, pageSize: 400, total: 1200 }).page).toBe(3)
48
+ expect(pageInfo({ page: 0, pageSize: 400, total: 1200 }).page).toBe(1)
49
+ expect(pageInfo({ page: -5, pageSize: 400, total: 1200 }).page).toBe(1)
50
+ expect(pageInfo({ page: Number.NaN, pageSize: 400, total: 1200 }).page).toBe(1)
51
+ })
52
+
53
+ it("sans pagination : une seule page, tout est affiché", () => {
54
+ expect(pageInfo({ page: 1, pageSize: 0, total: 250 })).toEqual({
55
+ page: 1,
56
+ pageCount: 1,
57
+ offset: 0,
58
+ from: 1,
59
+ to: 250,
60
+ })
61
+ })
62
+
63
+ it("total nul : from/to à 0", () => {
64
+ expect(pageInfo({ page: 1, pageSize: 400, total: 0 })).toEqual({
65
+ page: 1,
66
+ pageCount: 1,
67
+ offset: 0,
68
+ from: 0,
69
+ to: 0,
70
+ })
71
+ })
72
+
73
+ it("ne dépasse jamais le total", () => {
74
+ // `loaded` plus grand que le quota (données incohérentes) → plage bornée au total
75
+ expect(pageInfo({ page: 1, pageSize: 400, total: 10, loaded: 400 }).to).toBe(10)
76
+ })
77
+ })
@@ -0,0 +1,50 @@
1
+ // Pagination « distante » d'une grille — calculs **purs** (aucun DOM, aucun état).
2
+ //
3
+ // Le tableau ne découpe rien lui-même : `rows` contient **la page courante**, chargée par
4
+ // l'app. Ce module borne la page, calcule le nombre de pages et la plage globale des lignes
5
+ // affichées (numérotation des en-têtes de ligne, « lignes 401–800 sur 12 000 »).
6
+
7
+ export interface PageInfo {
8
+ /** Page courante, bornée à `[1, pageCount]` */
9
+ page: number
10
+ /** Nombre de pages (toujours ≥ 1) */
11
+ pageCount: number
12
+ /** Décalage global de la première ligne affichée (0-based) */
13
+ offset: number
14
+ /** Numéro global (1-based) de la première ligne affichée — `0` si le total est nul */
15
+ from: number
16
+ /** Numéro global (1-based) de la dernière ligne affichée — `0` si le total est nul */
17
+ to: number
18
+ }
19
+
20
+ export interface PageInfoOptions {
21
+ /** Page demandée (1-based) — bornée au résultat */
22
+ page: number
23
+ /** Lignes par page (0 = pas de pagination) */
24
+ pageSize: number
25
+ /** Total de lignes, toutes pages confondues */
26
+ total: number
27
+ /** Lignes réellement fournies pour cette page (défaut : `pageSize`, ou `total` sans pagination) */
28
+ loaded?: number
29
+ }
30
+
31
+ /**
32
+ * Calcule l'état de pagination. Une page hors bornes est **ramenée** dans `[1, pageCount]`
33
+ * (un total qui rétrécit après un filtre ne laisse pas la vue sur une page vide), et une
34
+ * dernière page peut être partielle (`loaded` < `pageSize`).
35
+ */
36
+ export const pageInfo = (options: PageInfoOptions): PageInfo => {
37
+ const size = Math.max(0, Math.trunc(options.pageSize || 0))
38
+ const total = Math.max(0, Math.trunc(options.total || 0))
39
+ const pageCount = size > 0 ? Math.max(1, Math.ceil(total / size)) : 1
40
+
41
+ const raw = Number.isFinite(options.page) ? Math.trunc(options.page) : 1
42
+ const page = Math.min(Math.max(1, raw), pageCount)
43
+ const offset = size > 0 ? (page - 1) * size : 0
44
+
45
+ const loaded = Math.max(0, Math.trunc(options.loaded ?? (size > 0 ? size : total)))
46
+ const from = total === 0 ? 0 : Math.min(offset + 1, total)
47
+ const to = total === 0 ? 0 : Math.min(offset + loaded, total)
48
+
49
+ return { page, pageCount, offset, from, to }
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.75",
3
+ "version": "0.0.76",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",