@nikala-ui/hooks 0.11.0 → 0.12.1

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,267 @@
1
+ import {
2
+ createSignal,
3
+ createMemo,
4
+ onMount,
5
+ onCleanup,
6
+ type Accessor,
7
+ } from "solid-js";
8
+
9
+ export interface TabItem<T = any> {
10
+ id: string;
11
+ title: string;
12
+ icon?: any;
13
+ isDirty?: boolean;
14
+ isPinned?: boolean;
15
+ closable?: boolean;
16
+ data?: T;
17
+ }
18
+
19
+ export interface CreateDocumentTabsOptions<T = any> {
20
+ /** Initial array of tab items. */
21
+ initialTabs?: TabItem<T>[];
22
+ /** Default active tab id. Defaults to first tab if available. */
23
+ defaultActiveId?: string;
24
+ /** Whether to enable keyboard shortcuts (Ctrl/Cmd+W to close active, Ctrl/Cmd+T for new tab). */
25
+ enableKeybindings?: boolean;
26
+ /** Callback triggered when the active tab changes. */
27
+ onTabChange?: (tab: TabItem<T> | undefined) => void;
28
+ /** Callback triggered before or when a tab closes. Return false to prevent closing. */
29
+ onTabClose?: (tab: TabItem<T>) => boolean | void;
30
+ /** Callback triggered when a new tab is created. */
31
+ onTabAdd?: (tab: TabItem<T>) => void;
32
+ }
33
+
34
+ export interface CreateDocumentTabsReturn<T = any> {
35
+ /** All tabs list. */
36
+ tabs: Accessor<TabItem<T>[]>;
37
+ /** Currently active tab ID. */
38
+ activeTabId: Accessor<string>;
39
+ /** Currently active tab object. */
40
+ activeTab: Accessor<TabItem<T> | undefined>;
41
+ /** Pinned tabs only. */
42
+ pinnedTabs: Accessor<TabItem<T>[]>;
43
+ /** Standard unpinned tabs. */
44
+ unpinnedTabs: Accessor<TabItem<T>[]>;
45
+ /** Whether any open tab has unsaved changes (isDirty). */
46
+ hasDirtyTabs: Accessor<boolean>;
47
+ /** Count of total open tabs. */
48
+ count: Accessor<number>;
49
+ /** Add a new tab to the list. */
50
+ addTab: (tab: TabItem<T>, activate?: boolean) => void;
51
+ /** Close a tab by ID with auto-switching to adjacent tab. */
52
+ closeTab: (id: string) => boolean;
53
+ /** Set the active tab. */
54
+ setActiveTab: (id: string) => void;
55
+ /** Mark or unmark a tab as dirty (unsaved changes). */
56
+ markDirty: (id: string, isDirty?: boolean) => void;
57
+ /** Toggle pinned state of a tab. */
58
+ togglePin: (id: string) => void;
59
+ /** Close all tabs except the specified one. */
60
+ closeOthers: (id: string) => void;
61
+ /** Close all open unpinned tabs. */
62
+ closeAll: (includePinned?: boolean) => void;
63
+ /** Switch to the next tab in sequence. */
64
+ nextTab: () => void;
65
+ /** Switch to the previous tab in sequence. */
66
+ prevTab: () => void;
67
+ /** Reorder tabs from one position to another. */
68
+ reorderTabs: (fromIndex: number, toIndex: number) => void;
69
+ }
70
+
71
+ function findLastPinnedIndex<T>(items: TabItem<T>[]): number {
72
+ for (let i = items.length - 1; i >= 0; i--) {
73
+ if (items[i].isPinned) return i;
74
+ }
75
+ return -1;
76
+ }
77
+
78
+ /**
79
+ * SolidJS reactive primitive for managing multi-document tabs, editor buffers, and browser tab states.
80
+ */
81
+ export function createDocumentTabs<T = any>(
82
+ options: CreateDocumentTabsOptions<T> = {}
83
+ ): CreateDocumentTabsReturn<T> {
84
+ const initial = options.initialTabs || [];
85
+ const [tabs, setTabs] = createSignal<TabItem<T>[]>(initial);
86
+ const [activeTabId, setActiveTabId] = createSignal<string>(
87
+ options.defaultActiveId || (initial.length > 0 ? initial[0].id : "")
88
+ );
89
+
90
+ const activeTab = createMemo(() => tabs().find((t) => t.id === activeTabId()));
91
+ const pinnedTabs = createMemo(() => tabs().filter((t) => t.isPinned));
92
+ const unpinnedTabs = createMemo(() => tabs().filter((t) => !t.isPinned));
93
+ const hasDirtyTabs = createMemo(() => tabs().some((t) => t.isDirty));
94
+ const count = createMemo(() => tabs().length);
95
+
96
+ const setActive = (id: string) => {
97
+ const target = tabs().find((t) => t.id === id);
98
+ if (target) {
99
+ setActiveTabId(id);
100
+ options.onTabChange?.(target);
101
+ }
102
+ };
103
+
104
+ const addTab = (tab: TabItem<T>, activate = true) => {
105
+ const existing = tabs().find((t) => t.id === tab.id);
106
+ if (existing) {
107
+ if (activate) setActive(tab.id);
108
+ return;
109
+ }
110
+
111
+ setTabs((prev) => {
112
+ if (tab.isPinned) {
113
+ const lastPinnedIdx = findLastPinnedIndex(prev);
114
+ if (lastPinnedIdx === -1) return [tab, ...prev];
115
+ const next = [...prev];
116
+ next.splice(lastPinnedIdx + 1, 0, tab);
117
+ return next;
118
+ }
119
+ return [...prev, tab];
120
+ });
121
+
122
+ if (activate) {
123
+ setActiveTabId(tab.id);
124
+ options.onTabChange?.(tab);
125
+ }
126
+ options.onTabAdd?.(tab);
127
+ };
128
+
129
+ const closeTab = (id: string): boolean => {
130
+ const currentTabs = tabs();
131
+ const tabToClose = currentTabs.find((t) => t.id === id);
132
+ if (!tabToClose) return false;
133
+
134
+ if (options.onTabClose && options.onTabClose(tabToClose) === false) {
135
+ return false;
136
+ }
137
+
138
+ const closeIdx = currentTabs.findIndex((t) => t.id === id);
139
+ const nextTabs = currentTabs.filter((t) => t.id !== id);
140
+ setTabs(nextTabs);
141
+
142
+ if (activeTabId() === id) {
143
+ if (nextTabs.length === 0) {
144
+ setActiveTabId("");
145
+ options.onTabChange?.(undefined);
146
+ } else {
147
+ const nextIdx = Math.min(closeIdx, nextTabs.length - 1);
148
+ const nextActive = nextTabs[nextIdx];
149
+ setActiveTabId(nextActive.id);
150
+ options.onTabChange?.(nextActive);
151
+ }
152
+ }
153
+ return true;
154
+ };
155
+
156
+ const markDirty = (id: string, isDirty = true) => {
157
+ setTabs((prev) =>
158
+ prev.map((t) => (t.id === id ? { ...t, isDirty } : t))
159
+ );
160
+ };
161
+
162
+ const togglePin = (id: string) => {
163
+ setTabs((prev) => {
164
+ const target = prev.find((t) => t.id === id);
165
+ if (!target) return prev;
166
+ const updated = { ...target, isPinned: !target.isPinned };
167
+ const remaining = prev.filter((t) => t.id !== id);
168
+
169
+ if (updated.isPinned) {
170
+ const lastPinnedIdx = findLastPinnedIndex(remaining);
171
+ if (lastPinnedIdx === -1) return [updated, ...remaining];
172
+ remaining.splice(lastPinnedIdx + 1, 0, updated);
173
+ return remaining;
174
+ }
175
+ return [...remaining, updated];
176
+ });
177
+ };
178
+
179
+ const closeOthers = (id: string) => {
180
+ setTabs((prev) => prev.filter((t) => t.id === id || t.isPinned));
181
+ setActive(id);
182
+ };
183
+
184
+ const closeAll = (includePinned = false) => {
185
+ if (includePinned) {
186
+ setTabs([]);
187
+ setActiveTabId("");
188
+ options.onTabChange?.(undefined);
189
+ } else {
190
+ const pinned = tabs().filter((t) => t.isPinned);
191
+ setTabs(pinned);
192
+ if (pinned.length > 0) {
193
+ setActiveTabId(pinned[0].id);
194
+ options.onTabChange?.(pinned[0]);
195
+ } else {
196
+ setActiveTabId("");
197
+ options.onTabChange?.(undefined);
198
+ }
199
+ }
200
+ };
201
+
202
+ const nextTab = () => {
203
+ const list = tabs();
204
+ if (list.length <= 1) return;
205
+ const currentIdx = list.findIndex((t) => t.id === activeTabId());
206
+ const nextIdx = (currentIdx + 1) % list.length;
207
+ setActive(list[nextIdx].id);
208
+ };
209
+
210
+ const prevTab = () => {
211
+ const list = tabs();
212
+ if (list.length <= 1) return;
213
+ const currentIdx = list.findIndex((t) => t.id === activeTabId());
214
+ const prevIdx = (currentIdx - 1 + list.length) % list.length;
215
+ setActive(list[prevIdx].id);
216
+ };
217
+
218
+ const reorderTabs = (fromIndex: number, toIndex: number) => {
219
+ setTabs((prev) => {
220
+ if (fromIndex < 0 || fromIndex >= prev.length || toIndex < 0 || toIndex >= prev.length) {
221
+ return prev;
222
+ }
223
+ const clone = [...prev];
224
+ const [moved] = clone.splice(fromIndex, 1);
225
+ clone.splice(toIndex, 0, moved);
226
+ return clone;
227
+ });
228
+ };
229
+
230
+ onMount(() => {
231
+ if (!options.enableKeybindings || typeof window === "undefined") return;
232
+
233
+ const handleKeyDown = (e: KeyboardEvent) => {
234
+ const isCmdOrCtrl = e.metaKey || e.ctrlKey;
235
+ if (isCmdOrCtrl && (e.key === "w" || e.key === "W")) {
236
+ const active = activeTabId();
237
+ if (active) {
238
+ e.preventDefault();
239
+ closeTab(active);
240
+ }
241
+ }
242
+ };
243
+
244
+ window.addEventListener("keydown", handleKeyDown);
245
+ onCleanup(() => window.removeEventListener("keydown", handleKeyDown));
246
+ });
247
+
248
+ return {
249
+ tabs,
250
+ activeTabId,
251
+ activeTab,
252
+ pinnedTabs,
253
+ unpinnedTabs,
254
+ hasDirtyTabs,
255
+ count,
256
+ addTab,
257
+ closeTab,
258
+ setActiveTab: setActive,
259
+ markDirty,
260
+ togglePin,
261
+ closeOthers,
262
+ closeAll,
263
+ nextTab,
264
+ prevTab,
265
+ reorderTabs,
266
+ };
267
+ }
@@ -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
+ }