@nikala-ui/hooks 0.10.1-nightly.4e09f2a → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nikala-ui/hooks",
3
- "version": "0.10.1-nightly.4e09f2a",
3
+ "version": "0.11.0",
4
4
  "description": "Reactive SolidJS primitives and primitives for Nikala UI",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,113 @@
1
+ import { createSignal, createEffect, onCleanup, onMount, type Accessor } from "solid-js";
2
+
3
+ export interface CreateChatScrollOptions {
4
+ /** Target scrollable container element or accessor */
5
+ target: HTMLElement | Accessor<HTMLElement | undefined>;
6
+ /** Dependency accessor (e.g. messages length or content signal) that triggers auto-scroll when changed */
7
+ trigger?: Accessor<any>;
8
+ /** Threshold in pixels from bottom to consider the container "at bottom". Defaults to 40. */
9
+ threshold?: number;
10
+ /** Whether auto-scroll is enabled. Defaults to true. */
11
+ enabled?: boolean | Accessor<boolean>;
12
+ /** Scroll behavior: "smooth" or "auto". Defaults to "smooth". */
13
+ behavior?: ScrollBehavior;
14
+ }
15
+
16
+ export interface CreateChatScrollReturn {
17
+ /** Accessor indicating whether the container is currently scrolled to the bottom */
18
+ isAtBottom: Accessor<boolean>;
19
+ /** Accessor indicating whether user has manually scrolled up away from bottom */
20
+ isScrolledUp: Accessor<boolean>;
21
+ /** Programmatically scroll container directly to the bottom */
22
+ scrollToBottom: (options?: { smooth?: boolean }) => void;
23
+ }
24
+
25
+ /**
26
+ * SolidJS reactive primitive for chat and streaming message auto-scrolling with user scroll detection.
27
+ *
28
+ * @param options Chat scroll configuration options.
29
+ */
30
+ export function createChatScroll(options: CreateChatScrollOptions): CreateChatScrollReturn {
31
+ const [isAtBottom, setIsAtBottom] = createSignal<boolean>(true);
32
+ const isScrolledUp = () => !isAtBottom();
33
+
34
+ const getElement = (): HTMLElement | undefined => {
35
+ if (typeof options.target === "function") {
36
+ return (options.target as Accessor<HTMLElement | undefined>)();
37
+ }
38
+ return options.target;
39
+ };
40
+
41
+ const isEnabled = () => {
42
+ if (typeof options.enabled === "function") {
43
+ return (options.enabled as Accessor<boolean>)();
44
+ }
45
+ return options.enabled ?? true;
46
+ };
47
+
48
+ const threshold = options.threshold ?? 40;
49
+
50
+ const checkIfAtBottom = () => {
51
+ const el = getElement();
52
+ if (!el) return true;
53
+ const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
54
+ return distanceToBottom <= threshold;
55
+ };
56
+
57
+ const scrollToBottom = (opts?: { smooth?: boolean }) => {
58
+ const el = getElement();
59
+ if (!el) return;
60
+
61
+ const useSmooth = opts?.smooth ?? (options.behavior === "smooth" || options.behavior === undefined);
62
+
63
+ el.scrollTo({
64
+ top: el.scrollHeight,
65
+ behavior: useSmooth ? "smooth" : "auto",
66
+ });
67
+ setIsAtBottom(true);
68
+ };
69
+
70
+ const handleScroll = () => {
71
+ const atBottom = checkIfAtBottom();
72
+ setIsAtBottom(atBottom);
73
+ };
74
+
75
+ onMount(() => {
76
+ if (typeof window === "undefined") return;
77
+
78
+ const el = getElement();
79
+ if (el) {
80
+ el.addEventListener("scroll", handleScroll, { passive: true });
81
+ setIsAtBottom(checkIfAtBottom());
82
+ }
83
+ });
84
+
85
+ onCleanup(() => {
86
+ if (typeof window === "undefined") return;
87
+ const el = getElement();
88
+ if (el) {
89
+ el.removeEventListener("scroll", handleScroll);
90
+ }
91
+ });
92
+
93
+ // Watch trigger dependencies (e.g. messages length or stream tokens)
94
+ if (options.trigger) {
95
+ createEffect(() => {
96
+ // Track trigger dependency
97
+ options.trigger!();
98
+
99
+ if (isEnabled() && isAtBottom()) {
100
+ // Run after microtask/DOM paint
101
+ setTimeout(() => {
102
+ scrollToBottom({ smooth: true });
103
+ }, 10);
104
+ }
105
+ });
106
+ }
107
+
108
+ return {
109
+ isAtBottom,
110
+ isScrolledUp,
111
+ scrollToBottom,
112
+ };
113
+ }
@@ -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
+ }
package/src/index.ts CHANGED
@@ -38,4 +38,6 @@ export * from "./create-document-title";
38
38
  export * from "./create-favicon";
39
39
  export * from "./create-event-source";
40
40
  export * from "./create-scroll-into-view";
41
- export * from "./create-drop-zone";
41
+ export * from "./create-drop-zone";
42
+ export * from "./create-pagination";
43
+ export * from "./create-chat-scroll";