@nikala-ui/hooks 0.10.1 → 0.11.0-nightly.b01fa01

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.
@@ -0,0 +1,317 @@
1
+ import { createSignal, onCleanup, onMount, type Accessor } from "solid-js";
2
+
3
+ export interface FileRejection {
4
+ file: File;
5
+ errors: Array<{
6
+ code: "file-invalid-type" | "file-too-large" | "file-too-small" | "too-many-files";
7
+ message: string;
8
+ }>;
9
+ }
10
+
11
+ export interface CreateDropZoneOptions {
12
+ /** Accepted file types: MIME types (e.g. "image/*", "application/pdf") or extensions (e.g. ".png", ".jpg") */
13
+ accept?: string | string[];
14
+ /** Maximum number of files allowed */
15
+ maxFiles?: number;
16
+ /** Maximum file size in bytes */
17
+ maxSize?: number;
18
+ /** Minimum file size in bytes */
19
+ minSize?: number;
20
+ /** Whether multiple files are allowed. Defaults to true */
21
+ multiple?: boolean;
22
+ /** Whether the drop zone is disabled */
23
+ disabled?: boolean | Accessor<boolean>;
24
+ /** Prevents browser default behavior of opening files dropped outside dropzone. Defaults to true */
25
+ preventDropOnDocument?: boolean;
26
+ /** Callback fired when valid files are dropped */
27
+ onDrop?: (files: File[], event: DragEvent) => void;
28
+ /** Callback fired when some or all files fail validation */
29
+ onDropRejected?: (rejectedFiles: FileRejection[], event: DragEvent) => void;
30
+ /** Callback fired when drag enters the dropzone */
31
+ onDragEnter?: (event: DragEvent) => void;
32
+ /** Callback fired when drag leaves the dropzone */
33
+ onDragLeave?: (event: DragEvent) => void;
34
+ /** Callback fired when dragging over the dropzone */
35
+ onDragOver?: (event: DragEvent) => void;
36
+ /** Callback fired whenever accepted files list changes */
37
+ onFilesChanged?: (files: File[]) => void;
38
+ }
39
+
40
+ export interface CreateDropZoneReturn {
41
+ /** Whether drag operation is currently active over the target drop zone */
42
+ isOver: Accessor<boolean>;
43
+ /** Whether files are currently being dragged anywhere on the window */
44
+ isDragging: Accessor<boolean>;
45
+ /** Currently accepted dropped files */
46
+ files: Accessor<File[]>;
47
+ /** Currently rejected files with error details */
48
+ rejectedFiles: Accessor<FileRejection[]>;
49
+ /** Clear all accepted and rejected files */
50
+ clear: () => void;
51
+ /** Programmatically set files (e.g. from an <input type="file" /> change event) */
52
+ setFiles: (files: File[]) => void;
53
+ /** Programmatically open the native browser file selector dialog */
54
+ openFileDialog: () => void;
55
+ /** Ref callback to attach to target DOM element */
56
+ ref: (el: HTMLElement) => void;
57
+ /** Event handler props to spread directly onto target JSX element */
58
+ props: {
59
+ onDragEnter: (e: DragEvent) => void;
60
+ onDragLeave: (e: DragEvent) => void;
61
+ onDragOver: (e: DragEvent) => void;
62
+ onDrop: (e: DragEvent) => void;
63
+ };
64
+ }
65
+
66
+ function matchesAccept(file: File, acceptList: string[]): boolean {
67
+ if (acceptList.length === 0) return true;
68
+ const fileName = file.name.toLowerCase();
69
+ const fileType = file.type.toLowerCase();
70
+
71
+ return acceptList.some((pattern) => {
72
+ const p = pattern.trim().toLowerCase();
73
+ if (p.startsWith(".")) {
74
+ return fileName.endsWith(p);
75
+ }
76
+ if (p.endsWith("/*")) {
77
+ const typePrefix = p.slice(0, -2);
78
+ return fileType.startsWith(typePrefix + "/");
79
+ }
80
+ return fileType === p;
81
+ });
82
+ }
83
+
84
+ function validateFiles(
85
+ incomingFiles: File[],
86
+ options: CreateDropZoneOptions
87
+ ): { accepted: File[]; rejected: FileRejection[] } {
88
+ const acceptList = options.accept
89
+ ? (Array.isArray(options.accept) ? options.accept : options.accept.split(","))
90
+ .map((s) => s.trim())
91
+ .filter(Boolean)
92
+ : [];
93
+
94
+ const maxFiles = options.maxFiles ?? (options.multiple === false ? 1 : Infinity);
95
+ const maxSize = options.maxSize;
96
+ const minSize = options.minSize;
97
+
98
+ const accepted: File[] = [];
99
+ const rejected: FileRejection[] = [];
100
+
101
+ incomingFiles.forEach((file, index) => {
102
+ const errors: FileRejection["errors"] = [];
103
+
104
+ if (index >= maxFiles) {
105
+ errors.push({
106
+ code: "too-many-files",
107
+ message: `Maximum allowed files is ${maxFiles}.`,
108
+ });
109
+ }
110
+
111
+ if (acceptList.length > 0 && !matchesAccept(file, acceptList)) {
112
+ errors.push({
113
+ code: "file-invalid-type",
114
+ message: `File type "${file.type || file.name.split(".").pop()}" is not allowed.`,
115
+ });
116
+ }
117
+
118
+ if (maxSize !== undefined && file.size > maxSize) {
119
+ errors.push({
120
+ code: "file-too-large",
121
+ message: `File size exceeds ${(maxSize / (1024 * 1024)).toFixed(1)}MB limit.`,
122
+ });
123
+ }
124
+
125
+ if (minSize !== undefined && file.size < minSize) {
126
+ errors.push({
127
+ code: "file-too-small",
128
+ message: `File size is below ${(minSize / 1024).toFixed(1)}KB limit.`,
129
+ });
130
+ }
131
+
132
+ if (errors.length > 0) {
133
+ rejected.push({ file, errors });
134
+ } else {
135
+ accepted.push(file);
136
+ }
137
+ });
138
+
139
+ return { accepted, rejected };
140
+ }
141
+
142
+ /**
143
+ * SolidJS reactive primitive for managing file drag & drop zones with validation and file dialog support.
144
+ *
145
+ * @param options Configuration options for file acceptance, size limits, and callbacks.
146
+ */
147
+ export function createDropZone(options: CreateDropZoneOptions = {}): CreateDropZoneReturn {
148
+ const [isOver, setIsOver] = createSignal(false);
149
+ const [isDragging, setIsDragging] = createSignal(false);
150
+ const [files, setFilesInternal] = createSignal<File[]>([]);
151
+ const [rejectedFiles, setRejectedFilesInternal] = createSignal<FileRejection[]>([]);
152
+
153
+ let dragCounter = 0;
154
+ let windowDragCounter = 0;
155
+ let targetElement: HTMLElement | null = null;
156
+
157
+ const isDisabled = () => {
158
+ if (typeof options.disabled === "function") {
159
+ return (options.disabled as Accessor<boolean>)();
160
+ }
161
+ return options.disabled ?? false;
162
+ };
163
+
164
+ const processFiles = (incomingFiles: File[], event: DragEvent) => {
165
+ const { accepted, rejected } = validateFiles(incomingFiles, options);
166
+
167
+ setFilesInternal(accepted);
168
+ setRejectedFilesInternal(rejected);
169
+
170
+ if (accepted.length > 0) {
171
+ options.onDrop?.(accepted, event);
172
+ options.onFilesChanged?.(accepted);
173
+ }
174
+
175
+ if (rejected.length > 0) {
176
+ options.onDropRejected?.(rejected, event);
177
+ }
178
+ };
179
+
180
+ const onDragEnter = (e: DragEvent) => {
181
+ if (isDisabled()) return;
182
+ e.preventDefault();
183
+ dragCounter++;
184
+ if (dragCounter === 1) {
185
+ setIsOver(true);
186
+ options.onDragEnter?.(e);
187
+ }
188
+ };
189
+
190
+ const onDragOver = (e: DragEvent) => {
191
+ if (isDisabled()) return;
192
+ e.preventDefault();
193
+ if (e.dataTransfer) {
194
+ e.dataTransfer.dropEffect = "copy";
195
+ }
196
+ options.onDragOver?.(e);
197
+ };
198
+
199
+ const onDragLeave = (e: DragEvent) => {
200
+ if (isDisabled()) return;
201
+ e.preventDefault();
202
+ dragCounter--;
203
+ if (dragCounter <= 0) {
204
+ dragCounter = 0;
205
+ setIsOver(false);
206
+ options.onDragLeave?.(e);
207
+ }
208
+ };
209
+
210
+ const onDrop = (e: DragEvent) => {
211
+ if (isDisabled()) return;
212
+ e.preventDefault();
213
+ dragCounter = 0;
214
+ setIsOver(false);
215
+
216
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
217
+ processFiles(Array.from(e.dataTransfer.files), e);
218
+ }
219
+ };
220
+
221
+ const clear = () => {
222
+ setFilesInternal([]);
223
+ setRejectedFilesInternal([]);
224
+ options.onFilesChanged?.([]);
225
+ };
226
+
227
+ const setFiles = (newFiles: File[]) => {
228
+ const dummyEvent = new Event("drop") as unknown as DragEvent;
229
+ processFiles(newFiles, dummyEvent);
230
+ };
231
+
232
+ const openFileDialog = () => {
233
+ if (typeof document === "undefined" || isDisabled()) return;
234
+ const input = document.createElement("input");
235
+ input.type = "file";
236
+ if (options.multiple !== false && (options.maxFiles === undefined || options.maxFiles > 1)) {
237
+ input.multiple = true;
238
+ }
239
+ if (options.accept) {
240
+ input.accept = Array.isArray(options.accept) ? options.accept.join(",") : options.accept;
241
+ }
242
+ input.onchange = (e) => {
243
+ const target = e.target as HTMLInputElement;
244
+ if (target.files && target.files.length > 0) {
245
+ processFiles(Array.from(target.files), e as unknown as DragEvent);
246
+ }
247
+ };
248
+ input.click();
249
+ };
250
+
251
+ const ref = (el: HTMLElement) => {
252
+ targetElement = el;
253
+ };
254
+
255
+ // Window-level drag detection and document drop prevention
256
+ onMount(() => {
257
+ if (typeof window === "undefined") return;
258
+
259
+ const handleWindowDragEnter = (e: DragEvent) => {
260
+ windowDragCounter++;
261
+ if (windowDragCounter === 1) {
262
+ setIsDragging(true);
263
+ }
264
+ };
265
+
266
+ const handleWindowDragLeave = (e: DragEvent) => {
267
+ windowDragCounter--;
268
+ if (windowDragCounter <= 0) {
269
+ windowDragCounter = 0;
270
+ setIsDragging(false);
271
+ }
272
+ };
273
+
274
+ const handleWindowDrop = (e: DragEvent) => {
275
+ windowDragCounter = 0;
276
+ setIsDragging(false);
277
+ if (options.preventDropOnDocument !== false) {
278
+ e.preventDefault();
279
+ }
280
+ };
281
+
282
+ const handleWindowDragOver = (e: DragEvent) => {
283
+ if (options.preventDropOnDocument !== false) {
284
+ e.preventDefault();
285
+ }
286
+ };
287
+
288
+ window.addEventListener("dragenter", handleWindowDragEnter);
289
+ window.addEventListener("dragleave", handleWindowDragLeave);
290
+ window.addEventListener("dragover", handleWindowDragOver);
291
+ window.addEventListener("drop", handleWindowDrop);
292
+
293
+ onCleanup(() => {
294
+ window.removeEventListener("dragenter", handleWindowDragEnter);
295
+ window.removeEventListener("dragleave", handleWindowDragLeave);
296
+ window.removeEventListener("dragover", handleWindowDragOver);
297
+ window.removeEventListener("drop", handleWindowDrop);
298
+ });
299
+ });
300
+
301
+ return {
302
+ isOver,
303
+ isDragging,
304
+ files,
305
+ rejectedFiles,
306
+ clear,
307
+ setFiles,
308
+ openFileDialog,
309
+ ref,
310
+ props: {
311
+ onDragEnter,
312
+ onDragLeave,
313
+ onDragOver,
314
+ onDrop,
315
+ },
316
+ };
317
+ }
@@ -0,0 +1,140 @@
1
+ import { onMount, onCleanup } from "solid-js";
2
+ import { isTauriEnvironment } from "./create-tauri-window";
3
+
4
+ export interface CreateGlobalShortcutOptions {
5
+ /** Shortcut key combination, e.g. 'CommandOrControl+Shift+P' or 'Alt+Space'. */
6
+ shortcut: string | string[];
7
+ /** Handler invoked when global shortcut is triggered. */
8
+ onTrigger: (shortcut: string) => void;
9
+ /** Automatically register shortcut on mount. Defaults to true. */
10
+ autoRegister?: boolean;
11
+ }
12
+
13
+ export interface CreateGlobalShortcutReturn {
14
+ /** Register the configured global shortcut with Tauri / Browser. */
15
+ register: () => Promise<boolean>;
16
+ /** Unregister the shortcut. */
17
+ unregister: () => Promise<void>;
18
+ /** Indicates whether the shortcut is currently registered. */
19
+ isRegistered: () => boolean;
20
+ }
21
+
22
+ /**
23
+ * SolidJS reactive primitive for registering native OS global hotkeys via Tauri v2 global shortcut plugin,
24
+ * with automatic fallback to browser DOM keyboard listeners.
25
+ */
26
+ export function createGlobalShortcut(
27
+ options: CreateGlobalShortcutOptions
28
+ ): CreateGlobalShortcutReturn {
29
+ let registered = false;
30
+ const shortcuts = Array.isArray(options.shortcut) ? options.shortcut : [options.shortcut];
31
+
32
+ const getTauriShortcutPlugin = async () => {
33
+ if (typeof window === "undefined") return null;
34
+ try {
35
+ if ((window as any).__TAURI__?.globalShortcut) {
36
+ return (window as any).__TAURI__.globalShortcut;
37
+ }
38
+ // Variable prevents Vite dev server from static module resolution
39
+ const moduleName = "@tauri-apps/plugin-global-shortcut";
40
+ // @ts-ignore - Optional runtime dependency in Tauri apps
41
+ const plugin = await import(/* @vite-ignore */ moduleName).catch(() => null);
42
+ return plugin;
43
+ } catch {
44
+ return null;
45
+ }
46
+ };
47
+
48
+ const normalizeKey = (k: string) => {
49
+ const key = k.toLowerCase().trim();
50
+ if (key === " " || key === "space" || key === "spacebar") return "space";
51
+ if (key === "esc" || key === "escape") return "escape";
52
+ if (key === "return" || key === "enter") return "enter";
53
+ return key;
54
+ };
55
+
56
+ const handleBrowserKeydown = (e: KeyboardEvent) => {
57
+ const eventKey = normalizeKey(e.key);
58
+ const eventCode = normalizeKey(e.code.replace(/^Key|^Digit/, ""));
59
+
60
+ for (const keyCombo of shortcuts) {
61
+ const parts = keyCombo.toLowerCase().split("+");
62
+ const hasCtrlOrCmd = parts.includes("commandorcontrol") || parts.includes("ctrl") || parts.includes("cmd");
63
+ const hasShift = parts.includes("shift");
64
+ const hasAlt = parts.includes("alt");
65
+ const mainKey = parts.find((p) => !["commandorcontrol", "ctrl", "cmd", "shift", "alt"].includes(p));
66
+
67
+ const ctrlMatch = hasCtrlOrCmd ? e.ctrlKey || e.metaKey : !e.ctrlKey && !e.metaKey;
68
+ const shiftMatch = hasShift ? e.shiftKey : !e.shiftKey;
69
+ const altMatch = hasAlt ? e.altKey : !e.altKey;
70
+ const keyMatch = mainKey
71
+ ? eventKey === normalizeKey(mainKey) || eventCode === normalizeKey(mainKey)
72
+ : false;
73
+
74
+ if (ctrlMatch && shiftMatch && altMatch && keyMatch) {
75
+ e.preventDefault();
76
+ options.onTrigger(keyCombo);
77
+ break;
78
+ }
79
+ }
80
+ };
81
+
82
+ const register = async (): Promise<boolean> => {
83
+ if (typeof window === "undefined") return false;
84
+
85
+ if (isTauriEnvironment()) {
86
+ const plugin = await getTauriShortcutPlugin();
87
+ if (plugin?.register) {
88
+ try {
89
+ for (const s of shortcuts) {
90
+ await plugin.register(s, () => options.onTrigger(s));
91
+ }
92
+ registered = true;
93
+ return true;
94
+ } catch {
95
+ // Fallback to web listener
96
+ }
97
+ }
98
+ }
99
+
100
+ window.addEventListener("keydown", handleBrowserKeydown);
101
+ registered = true;
102
+ return true;
103
+ };
104
+
105
+ const unregister = async (): Promise<void> => {
106
+ if (typeof window === "undefined") return;
107
+
108
+ if (isTauriEnvironment()) {
109
+ const plugin = await getTauriShortcutPlugin();
110
+ if (plugin?.unregister) {
111
+ try {
112
+ for (const s of shortcuts) {
113
+ await plugin.unregister(s);
114
+ }
115
+ } catch {
116
+ // Handled
117
+ }
118
+ }
119
+ }
120
+
121
+ window.removeEventListener("keydown", handleBrowserKeydown);
122
+ registered = false;
123
+ };
124
+
125
+ onMount(() => {
126
+ if (options.autoRegister !== false) {
127
+ register();
128
+ }
129
+ });
130
+
131
+ onCleanup(() => {
132
+ unregister();
133
+ });
134
+
135
+ return {
136
+ register,
137
+ unregister,
138
+ isRegistered: () => registered,
139
+ };
140
+ }
@@ -0,0 +1,208 @@
1
+ import { createSignal, createMemo, type Accessor } from "solid-js";
2
+
3
+ export interface CreatePaginationOptions {
4
+ /** Total number of items across all pages, or total count. */
5
+ count?: number | Accessor<number>;
6
+ /** Explicit total number of pages. If provided, overrides count / pageSize calculation. */
7
+ totalPages?: number | Accessor<number>;
8
+ /** Controlled active page number (1-indexed). */
9
+ page?: number | Accessor<number>;
10
+ /** Default active page number for uncontrolled state. Defaults to 1. */
11
+ defaultPage?: number;
12
+ /** Number of items per page. Defaults to 10. */
13
+ pageSize?: number | Accessor<number>;
14
+ /** Number of sibling page buttons visible on each side of the current active page. Defaults to 1. */
15
+ siblingCount?: number | Accessor<number>;
16
+ /** Number of boundary pages visible at the beginning and end. Defaults to 1. */
17
+ boundaries?: number | Accessor<number>;
18
+ /** Callback fired whenever the active page changes. */
19
+ onChange?: (page: number) => void;
20
+ }
21
+
22
+ export interface CreatePaginationReturn {
23
+ /** Accessor returning the current active page number (1-indexed). */
24
+ page: Accessor<number>;
25
+ /** Accessor returning the calculated total number of pages. */
26
+ totalPages: Accessor<number>;
27
+ /** Accessor returning the active page size. */
28
+ pageSize: Accessor<number>;
29
+ /** Accessor returning an array of page numbers and "ellipsis" strings. */
30
+ range: Accessor<(number | "ellipsis")[]>;
31
+ /** Function to programmatically change to a specific page number. */
32
+ setPage: (page: number) => void;
33
+ /** Function to navigate to the next page. */
34
+ next: () => void;
35
+ /** Function to navigate to the previous page. */
36
+ previous: () => void;
37
+ /** Function to navigate to the first page (1). */
38
+ first: () => void;
39
+ /** Function to navigate to the last page (totalPages). */
40
+ last: () => void;
41
+ /** Accessor indicating whether a next page exists. */
42
+ hasNext: Accessor<boolean>;
43
+ /** Accessor indicating whether a previous page exists. */
44
+ hasPrevious: Accessor<boolean>;
45
+ /** 1-based start index of items on the current page (e.g. 1 for page 1 with pageSize 10). */
46
+ startIndex: Accessor<number>;
47
+ /** 1-based end index of items on the current page (e.g. 10 for page 1 with pageSize 10). */
48
+ endIndex: Accessor<number>;
49
+ }
50
+
51
+ /**
52
+ * SolidJS reactive primitive for computing pagination state, dynamic page range with ellipses, and navigation helpers.
53
+ *
54
+ * @param options Pagination configuration options.
55
+ */
56
+ export function createPagination(options: CreatePaginationOptions = {}): CreatePaginationReturn {
57
+ const getCount = () => {
58
+ const raw = typeof options.count === "function" ? options.count() : options.count ?? 0;
59
+ return Math.max(0, raw);
60
+ };
61
+
62
+ const getPageSize = () => {
63
+ const raw = typeof options.pageSize === "function" ? options.pageSize() : options.pageSize ?? 10;
64
+ return Math.max(1, raw);
65
+ };
66
+
67
+ const getExplicitTotalPages = () => {
68
+ const raw = typeof options.totalPages === "function" ? options.totalPages() : options.totalPages;
69
+ return raw !== undefined ? Math.max(1, raw) : undefined;
70
+ };
71
+
72
+ const getSiblingCount = () => {
73
+ const raw = typeof options.siblingCount === "function" ? options.siblingCount() : options.siblingCount ?? 1;
74
+ return Math.max(0, raw);
75
+ };
76
+
77
+ const getBoundaries = () => {
78
+ const raw = typeof options.boundaries === "function" ? options.boundaries() : options.boundaries ?? 1;
79
+ return Math.max(0, raw);
80
+ };
81
+
82
+ const [internalPage, setInternalPage] = createSignal<number>(Math.max(1, options.defaultPage ?? 1));
83
+
84
+ const totalPages = createMemo<number>(() => {
85
+ const explicit = getExplicitTotalPages();
86
+ if (explicit !== undefined) {
87
+ return Math.max(1, explicit);
88
+ }
89
+ const count = getCount();
90
+ const size = getPageSize();
91
+ return Math.max(1, Math.ceil(count / size));
92
+ });
93
+
94
+ const rawPage = () => {
95
+ if (typeof options.page === "function") {
96
+ return options.page();
97
+ }
98
+ if (typeof options.page === "number") {
99
+ return options.page;
100
+ }
101
+ return internalPage();
102
+ };
103
+
104
+ const page = createMemo<number>(() => {
105
+ const p = rawPage();
106
+ const max = totalPages();
107
+ if (p < 1) return 1;
108
+ if (p > max) return max;
109
+ return p;
110
+ });
111
+
112
+ const setPage = (nextPage: number) => {
113
+ const max = totalPages();
114
+ const clamped = Math.max(1, Math.min(nextPage, max));
115
+ if (typeof options.page !== "function" && typeof options.page !== "number") {
116
+ setInternalPage(clamped);
117
+ }
118
+ options.onChange?.(clamped);
119
+ };
120
+
121
+ const next = () => setPage(page() + 1);
122
+ const previous = () => setPage(page() - 1);
123
+ const first = () => setPage(1);
124
+ const last = () => setPage(totalPages());
125
+
126
+ const hasNext = createMemo(() => page() < totalPages());
127
+ const hasPrevious = createMemo(() => page() > 1);
128
+
129
+ const startIndex = createMemo(() => {
130
+ const count = getCount();
131
+ if (count === 0 && getExplicitTotalPages() === undefined) return 0;
132
+ return (page() - 1) * getPageSize() + 1;
133
+ });
134
+
135
+ const endIndex = createMemo(() => {
136
+ const count = getCount();
137
+ const calculated = page() * getPageSize();
138
+ if (count > 0) {
139
+ return Math.min(calculated, count);
140
+ }
141
+ return calculated;
142
+ });
143
+
144
+ const range = createMemo<(number | "ellipsis")[]>(() => {
145
+ const total = totalPages();
146
+ const current = page();
147
+ const siblings = getSiblingCount();
148
+ const boundaries = getBoundaries();
149
+
150
+ if (total <= 1) {
151
+ return [1];
152
+ }
153
+
154
+ const pagesSet = new Set<number>();
155
+
156
+ // 1. Boundary pages at the start
157
+ for (let i = 1; i <= Math.min(boundaries, total); i++) {
158
+ pagesSet.add(i);
159
+ }
160
+
161
+ // 2. Sibling pages around current page
162
+ const leftSibling = Math.max(1, current - siblings);
163
+ const rightSibling = Math.min(total, current + siblings);
164
+ for (let i = leftSibling; i <= rightSibling; i++) {
165
+ pagesSet.add(i);
166
+ }
167
+
168
+ // 3. Boundary pages at the end
169
+ for (let i = Math.max(1, total - boundaries + 1); i <= total; i++) {
170
+ pagesSet.add(i);
171
+ }
172
+
173
+ const sortedPages = Array.from(pagesSet).sort((a, b) => a - b);
174
+ const result: (number | "ellipsis")[] = [];
175
+
176
+ for (let i = 0; i < sortedPages.length; i++) {
177
+ const currentPageNum = sortedPages[i];
178
+ if (i > 0) {
179
+ const prevPageNum = sortedPages[i - 1];
180
+ const gap = currentPageNum - prevPageNum;
181
+ if (gap === 2) {
182
+ result.push(prevPageNum + 1);
183
+ } else if (gap > 2) {
184
+ result.push("ellipsis");
185
+ }
186
+ }
187
+ result.push(currentPageNum);
188
+ }
189
+
190
+ return result;
191
+ });
192
+
193
+ return {
194
+ page,
195
+ totalPages,
196
+ pageSize: getPageSize,
197
+ range,
198
+ setPage,
199
+ next,
200
+ previous,
201
+ first,
202
+ last,
203
+ hasNext,
204
+ hasPrevious,
205
+ startIndex,
206
+ endIndex,
207
+ };
208
+ }