react-multi-tab 1.0.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/dist/index.mjs ADDED
@@ -0,0 +1,618 @@
1
+ import { createContext, useRef, useEffect, useCallback, useMemo, useSyncExternalStore, useContext } from 'react';
2
+ import { jsx } from 'react/jsx-runtime';
3
+
4
+ // src/components/MultiTabProvider.tsx
5
+ var MultiTabContext = createContext(null);
6
+
7
+ // src/store.ts
8
+ function reducer(state, action) {
9
+ switch (action.type) {
10
+ case "OPEN_TAB": {
11
+ const activate = action.activate;
12
+ const newActiveId = activate ? action.tab.instanceId : state.activeTabId;
13
+ const newHistory = activate ? [
14
+ ...state.activeTabHistory.filter(
15
+ (id) => id !== action.tab.instanceId
16
+ ),
17
+ action.tab.instanceId
18
+ ] : state.activeTabHistory;
19
+ return {
20
+ ...state,
21
+ tabs: [...state.tabs, action.tab],
22
+ activeTabId: newActiveId,
23
+ activeTabHistory: newHistory
24
+ };
25
+ }
26
+ case "CLOSE_TAB": {
27
+ const newTabs = state.tabs.filter(
28
+ (t) => t.instanceId !== action.instanceId
29
+ );
30
+ const newHistory = state.activeTabHistory.filter(
31
+ (id) => id !== action.instanceId
32
+ );
33
+ let newActive = state.activeTabId;
34
+ if (state.activeTabId === action.instanceId) {
35
+ const previousValidActive = newHistory.length > 0 ? newHistory[newHistory.length - 1] : null;
36
+ newActive = previousValidActive || (newTabs.length > 0 ? newTabs[newTabs.length - 1].instanceId : null);
37
+ }
38
+ const { [action.instanceId]: _removed, ...restData } = state.tabData;
39
+ return {
40
+ tabs: newTabs,
41
+ activeTabId: newActive,
42
+ tabData: restData,
43
+ activeTabHistory: newHistory
44
+ };
45
+ }
46
+ case "ACTIVATE_TAB": {
47
+ if (!state.tabs.some((t) => t.instanceId === action.instanceId))
48
+ return state;
49
+ const newHistory = [
50
+ ...state.activeTabHistory.filter((id) => id !== action.instanceId),
51
+ action.instanceId
52
+ ];
53
+ return {
54
+ ...state,
55
+ activeTabId: action.instanceId,
56
+ activeTabHistory: newHistory
57
+ };
58
+ }
59
+ case "CLOSE_ALL":
60
+ return { tabs: [], activeTabId: null, tabData: {}, activeTabHistory: [] };
61
+ case "CLOSE_OTHERS": {
62
+ const kept = state.tabs.filter((t) => t.instanceId === action.instanceId);
63
+ const keptIds = new Set(kept.map((t) => t.instanceId));
64
+ const keptData = {};
65
+ for (const id of keptIds) {
66
+ if (state.tabData[id]) keptData[id] = state.tabData[id];
67
+ }
68
+ const newHistory = state.activeTabHistory.filter(
69
+ (id) => id === action.instanceId
70
+ );
71
+ return {
72
+ tabs: kept,
73
+ activeTabId: action.instanceId,
74
+ tabData: keptData,
75
+ activeTabHistory: newHistory
76
+ };
77
+ }
78
+ case "SET_DATA":
79
+ return {
80
+ ...state,
81
+ tabData: {
82
+ ...state.tabData,
83
+ [action.instanceId]: {
84
+ ...state.tabData[action.instanceId] ?? {},
85
+ ...action.data
86
+ }
87
+ }
88
+ };
89
+ case "REMOVE_DATA": {
90
+ const { [action.instanceId]: _removed, ...rest } = state.tabData;
91
+ return { ...state, tabData: rest };
92
+ }
93
+ case "RESTORE": {
94
+ const restoredActive = action.state.activeTabId;
95
+ const newHistory = restoredActive ? [
96
+ ...state.activeTabHistory.filter((id) => id !== restoredActive),
97
+ restoredActive
98
+ ] : state.activeTabHistory;
99
+ return { ...state, ...action.state, activeTabHistory: newHistory };
100
+ }
101
+ default:
102
+ return state;
103
+ }
104
+ }
105
+ function createMultiTabStore(initialState) {
106
+ let state = initialState;
107
+ const listeners = /* @__PURE__ */ new Set();
108
+ const getState = () => state;
109
+ const dispatch = (action) => {
110
+ state = reducer(state, action);
111
+ listeners.forEach((listener) => listener());
112
+ };
113
+ const subscribe = (listener) => {
114
+ listeners.add(listener);
115
+ return () => {
116
+ listeners.delete(listener);
117
+ };
118
+ };
119
+ return {
120
+ getState,
121
+ dispatch,
122
+ subscribe
123
+ };
124
+ }
125
+ var counter = 0;
126
+ function generateInstanceId(pageId) {
127
+ counter += 1;
128
+ return `${pageId}-${counter}`;
129
+ }
130
+ function MultiTabProvider({
131
+ registry,
132
+ adapter,
133
+ defaultActiveTab,
134
+ children,
135
+ onTabOpen,
136
+ onTabClose,
137
+ onTabChange
138
+ }) {
139
+ const store = useRef(
140
+ createMultiTabStore({
141
+ tabs: [],
142
+ activeTabId: defaultActiveTab ?? null,
143
+ tabData: {},
144
+ activeTabHistory: defaultActiveTab ? [defaultActiveTab] : []
145
+ })
146
+ ).current;
147
+ const cbRef = useRef({ onTabOpen, onTabClose, onTabChange });
148
+ cbRef.current = { onTabOpen, onTabClose, onTabChange };
149
+ const prevActiveRef = useRef(defaultActiveTab ?? null);
150
+ useEffect(
151
+ () => store.subscribe(() => {
152
+ const state = store.getState();
153
+ if (state.activeTabId !== null && state.activeTabId !== prevActiveRef.current) {
154
+ cbRef.current.onTabChange?.(state.activeTabId);
155
+ }
156
+ prevActiveRef.current = state.activeTabId;
157
+ }),
158
+ [store]
159
+ );
160
+ const initialised = useRef(false);
161
+ useEffect(() => {
162
+ if (initialised.current || !adapter) return;
163
+ initialised.current = true;
164
+ const saved = adapter.read();
165
+ if (!saved || saved.tabs.length === 0) return;
166
+ const restoredTabs = saved.tabs.map((instanceId) => {
167
+ const parts = instanceId.split("-");
168
+ for (let i = parts.length - 1; i >= 1; i--) {
169
+ const candidateId = parts.slice(0, i).join("-");
170
+ const page = registry.getPage(candidateId);
171
+ if (page) {
172
+ return {
173
+ instanceId,
174
+ pageId: page.id,
175
+ label: page.label,
176
+ closable: page.closable !== false
177
+ };
178
+ }
179
+ }
180
+ return null;
181
+ }).filter((t) => t !== null);
182
+ if (restoredTabs.length > 0) {
183
+ const activeTab = saved.activeTab && restoredTabs.some((t) => t.instanceId === saved.activeTab) ? saved.activeTab : restoredTabs[restoredTabs.length - 1].instanceId;
184
+ store.dispatch({
185
+ type: "RESTORE",
186
+ state: { tabs: restoredTabs, activeTabId: activeTab }
187
+ });
188
+ }
189
+ }, [adapter, registry, store]);
190
+ useEffect(() => {
191
+ if (!adapter) return;
192
+ return store.subscribe(() => {
193
+ const { tabs, activeTabId } = store.getState();
194
+ adapter.write(tabs, activeTabId);
195
+ });
196
+ }, [adapter, store]);
197
+ useEffect(() => {
198
+ if (!adapter?.subscribe) return;
199
+ const unsubscribe = adapter.subscribe(() => {
200
+ const saved = adapter.read();
201
+ const currentState = store.getState();
202
+ if (saved?.activeTab && saved.activeTab !== currentState.activeTabId) {
203
+ store.dispatch({ type: "ACTIVATE_TAB", instanceId: saved.activeTab });
204
+ }
205
+ });
206
+ return unsubscribe;
207
+ }, [adapter, store]);
208
+ useEffect(() => () => adapter?.destroy?.(), [adapter]);
209
+ const openTab = useCallback(
210
+ (pageId, options) => {
211
+ const page = registry.getPage(pageId);
212
+ if (!page) {
213
+ throw new Error(
214
+ `react-multi-tab: Page "${pageId}" not found in registry.`
215
+ );
216
+ }
217
+ const instanceId = options?.instanceId ?? generateInstanceId(pageId);
218
+ const tab = {
219
+ instanceId,
220
+ pageId,
221
+ label: options?.label ?? page.label,
222
+ closable: page.closable !== false
223
+ };
224
+ store.dispatch({
225
+ type: "OPEN_TAB",
226
+ tab,
227
+ activate: options?.activate !== false
228
+ });
229
+ cbRef.current.onTabOpen?.(tab);
230
+ return instanceId;
231
+ },
232
+ [registry, store]
233
+ );
234
+ const closeTab = useCallback(
235
+ (instanceId) => {
236
+ store.dispatch({ type: "CLOSE_TAB", instanceId });
237
+ cbRef.current.onTabClose?.(instanceId);
238
+ },
239
+ [store]
240
+ );
241
+ const activateTab = useCallback(
242
+ (instanceId) => {
243
+ store.dispatch({ type: "ACTIVATE_TAB", instanceId });
244
+ },
245
+ [store]
246
+ );
247
+ const closeAllTabs = useCallback(() => {
248
+ store.dispatch({ type: "CLOSE_ALL" });
249
+ }, [store]);
250
+ const closeOtherTabs = useCallback(
251
+ (instanceId) => {
252
+ store.dispatch({ type: "CLOSE_OTHERS", instanceId });
253
+ },
254
+ [store]
255
+ );
256
+ const setTabData = useCallback(
257
+ (instanceId, data) => {
258
+ store.dispatch({ type: "SET_DATA", instanceId, data });
259
+ },
260
+ [store]
261
+ );
262
+ const getTabData = useCallback(
263
+ (instanceId) => store.getState().tabData[instanceId] ?? {},
264
+ [store]
265
+ );
266
+ const removeTabData = useCallback(
267
+ (instanceId) => {
268
+ store.dispatch({ type: "REMOVE_DATA", instanceId });
269
+ },
270
+ [store]
271
+ );
272
+ const contextValue = useMemo(
273
+ () => ({
274
+ store,
275
+ registry,
276
+ openTab,
277
+ closeTab,
278
+ activateTab,
279
+ closeAllTabs,
280
+ closeOtherTabs,
281
+ setTabData,
282
+ getTabData,
283
+ removeTabData
284
+ }),
285
+ [
286
+ store,
287
+ registry,
288
+ openTab,
289
+ closeTab,
290
+ activateTab,
291
+ closeAllTabs,
292
+ closeOtherTabs,
293
+ setTabData,
294
+ getTabData,
295
+ removeTabData
296
+ ]
297
+ );
298
+ return /* @__PURE__ */ jsx(MultiTabContext.Provider, { value: contextValue, children });
299
+ }
300
+ function TabList({
301
+ children,
302
+ className,
303
+ style,
304
+ ...ariaProps
305
+ }) {
306
+ const listRef = useRef(null);
307
+ const handleKeyDown = useCallback(
308
+ (e) => {
309
+ const tabElements = listRef.current?.querySelectorAll('[role="tab"]');
310
+ if (!tabElements || tabElements.length === 0) return;
311
+ const tabs = Array.from(tabElements);
312
+ const currentIndex = tabs.findIndex(
313
+ (el) => el === document.activeElement
314
+ );
315
+ let nextIndex = null;
316
+ switch (e.key) {
317
+ case "ArrowRight":
318
+ case "ArrowDown":
319
+ e.preventDefault();
320
+ nextIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;
321
+ break;
322
+ case "ArrowLeft":
323
+ case "ArrowUp":
324
+ e.preventDefault();
325
+ nextIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
326
+ break;
327
+ case "Home":
328
+ e.preventDefault();
329
+ nextIndex = 0;
330
+ break;
331
+ case "End":
332
+ e.preventDefault();
333
+ nextIndex = tabs.length - 1;
334
+ break;
335
+ default:
336
+ return;
337
+ }
338
+ if (nextIndex !== null) {
339
+ tabs[nextIndex].focus();
340
+ }
341
+ },
342
+ []
343
+ );
344
+ return /* @__PURE__ */ jsx(
345
+ "div",
346
+ {
347
+ ref: listRef,
348
+ role: "tablist",
349
+ className,
350
+ style,
351
+ onKeyDown: handleKeyDown,
352
+ ...ariaProps,
353
+ children
354
+ }
355
+ );
356
+ }
357
+ function useInternalContext() {
358
+ const ctx = useContext(MultiTabContext);
359
+ if (!ctx) {
360
+ throw new Error(
361
+ "react-multi-tab: Hooks must be used within a <MultiTabProvider>."
362
+ );
363
+ }
364
+ return ctx;
365
+ }
366
+ function TabTrigger({
367
+ instanceId,
368
+ children,
369
+ className,
370
+ style,
371
+ disabled = false
372
+ }) {
373
+ const { store, activateTab } = useInternalContext();
374
+ const state = useSyncExternalStore(store.subscribe, store.getState);
375
+ const isActive = state.activeTabId === instanceId;
376
+ const handleClick = useCallback(() => {
377
+ if (!disabled) activateTab(instanceId);
378
+ }, [instanceId, disabled, activateTab]);
379
+ const handleKeyDown = useCallback(
380
+ (e) => {
381
+ if ((e.key === "Enter" || e.key === " ") && !disabled) {
382
+ e.preventDefault();
383
+ activateTab(instanceId);
384
+ }
385
+ },
386
+ [instanceId, disabled, activateTab]
387
+ );
388
+ return /* @__PURE__ */ jsx(
389
+ "div",
390
+ {
391
+ role: "tab",
392
+ id: `rmt-tab-${instanceId}`,
393
+ "aria-selected": isActive,
394
+ "aria-controls": `rmt-tabpanel-${instanceId}`,
395
+ tabIndex: isActive ? 0 : -1,
396
+ className,
397
+ style,
398
+ "aria-disabled": disabled,
399
+ onClick: handleClick,
400
+ onKeyDown: handleKeyDown,
401
+ "data-state": isActive ? "active" : "inactive",
402
+ children
403
+ }
404
+ );
405
+ }
406
+ function TabPanel({
407
+ instanceId,
408
+ children,
409
+ hidden = false,
410
+ className,
411
+ style
412
+ }) {
413
+ return /* @__PURE__ */ jsx(
414
+ "div",
415
+ {
416
+ role: "tabpanel",
417
+ id: `rmt-tabpanel-${instanceId}`,
418
+ "aria-labelledby": `rmt-tab-${instanceId}`,
419
+ tabIndex: 0,
420
+ hidden,
421
+ className,
422
+ style,
423
+ children
424
+ }
425
+ );
426
+ }
427
+ var TabInstanceContext = createContext(null);
428
+ function useTabInstanceId() {
429
+ const instanceId = useContext(TabInstanceContext);
430
+ if (!instanceId) {
431
+ throw new Error(
432
+ "react-multi-tab: useTabInstanceId must be used within a component rendered inside a Tab."
433
+ );
434
+ }
435
+ return instanceId;
436
+ }
437
+ function TabContent({ instanceId, children }) {
438
+ return /* @__PURE__ */ jsx(TabInstanceContext.Provider, { value: instanceId, children });
439
+ }
440
+ function TabPanels({ className, style }) {
441
+ const { store, registry, setTabData } = useInternalContext();
442
+ const state = useSyncExternalStore(store.subscribe, store.getState);
443
+ return /* @__PURE__ */ jsx("div", { className, style, children: state.tabs.map((tab) => {
444
+ const page = registry.getPage(tab.pageId);
445
+ if (!page) return null;
446
+ const isActive = state.activeTabId === tab.instanceId;
447
+ const data = state.tabData[tab.instanceId] ?? {};
448
+ const Component = page.component;
449
+ return /* @__PURE__ */ jsx(
450
+ TabPanel,
451
+ {
452
+ instanceId: tab.instanceId,
453
+ hidden: !isActive,
454
+ children: /* @__PURE__ */ jsx(TabContent, { instanceId: tab.instanceId, children: /* @__PURE__ */ jsx(
455
+ Component,
456
+ {
457
+ instanceId: tab.instanceId,
458
+ data,
459
+ setData: (update) => setTabData(tab.instanceId, update)
460
+ }
461
+ ) })
462
+ },
463
+ tab.instanceId
464
+ );
465
+ }) });
466
+ }
467
+ function TabCloseButton({
468
+ instanceId,
469
+ children,
470
+ className,
471
+ style,
472
+ "aria-label": ariaLabel
473
+ }) {
474
+ const { store, closeTab } = useInternalContext();
475
+ const state = useSyncExternalStore(store.subscribe, store.getState);
476
+ const tab = state.tabs.find((t) => t.instanceId === instanceId);
477
+ const handleClick = useCallback(
478
+ (e) => {
479
+ e.stopPropagation();
480
+ closeTab(instanceId);
481
+ },
482
+ [instanceId, closeTab]
483
+ );
484
+ if (!tab || !tab.closable) return null;
485
+ return /* @__PURE__ */ jsx(
486
+ "button",
487
+ {
488
+ type: "button",
489
+ "aria-label": ariaLabel ?? `Close ${tab.label}`,
490
+ className,
491
+ style,
492
+ onClick: handleClick,
493
+ tabIndex: -1,
494
+ children: children ?? "\xD7"
495
+ }
496
+ );
497
+ }
498
+ function useMultiTab() {
499
+ const {
500
+ store,
501
+ openTab,
502
+ closeTab,
503
+ activateTab,
504
+ closeAllTabs,
505
+ closeOtherTabs
506
+ } = useInternalContext();
507
+ const state = useSyncExternalStore(store.subscribe, store.getState);
508
+ return {
509
+ tabs: state.tabs,
510
+ activeTabId: state.activeTabId,
511
+ openTab,
512
+ closeTab,
513
+ activateTab,
514
+ closeAllTabs,
515
+ closeOtherTabs
516
+ };
517
+ }
518
+ var EMPTY_DATA = Object.freeze({});
519
+ function useTabData(instanceId) {
520
+ const { store, setTabData } = useInternalContext();
521
+ const contextId = useContext(TabInstanceContext);
522
+ const fallbackId = store.getState().activeTabId;
523
+ const resolvedId = instanceId ?? contextId ?? fallbackId;
524
+ const getSnapshot = useCallback(() => {
525
+ if (!resolvedId) return EMPTY_DATA;
526
+ const tabData = store.getState().tabData[resolvedId];
527
+ return tabData ?? EMPTY_DATA;
528
+ }, [resolvedId, store]);
529
+ const data = useSyncExternalStore(store.subscribe, getSnapshot);
530
+ const setData = useCallback(
531
+ (update) => {
532
+ if (resolvedId) {
533
+ setTabData(resolvedId, update);
534
+ }
535
+ },
536
+ [resolvedId, setTabData]
537
+ );
538
+ return [data, setData];
539
+ }
540
+
541
+ // src/registry.ts
542
+ function createPageRegistry(pages) {
543
+ const ids = /* @__PURE__ */ new Set();
544
+ for (const page of pages) {
545
+ if (!page.id) {
546
+ throw new Error("react-multi-tab: Page definition must have an id.");
547
+ }
548
+ if (!page.label) {
549
+ throw new Error(`react-multi-tab: Page "${page.id}" must have a label.`);
550
+ }
551
+ if (!page.component) {
552
+ throw new Error(
553
+ `react-multi-tab: Page "${page.id}" must have a component.`
554
+ );
555
+ }
556
+ if (ids.has(page.id)) {
557
+ throw new Error(`react-multi-tab: Duplicate page id: "${page.id}".`);
558
+ }
559
+ ids.add(page.id);
560
+ }
561
+ const frozen = [...pages];
562
+ return {
563
+ pages: frozen,
564
+ getPage: (id) => frozen.find((p) => p.id === id)
565
+ };
566
+ }
567
+
568
+ // src/adapters/memory.ts
569
+ function memoryAdapter() {
570
+ return {
571
+ read: () => null,
572
+ write: () => {
573
+ }
574
+ };
575
+ }
576
+
577
+ // src/adapters/search-params.ts
578
+ function searchParamsAdapter(options) {
579
+ const tabsKey = options?.tabsParam ?? "tabs";
580
+ const activeKey = options?.activeParam ?? "active";
581
+ return {
582
+ read() {
583
+ if (typeof window === "undefined") return null;
584
+ const params = new URLSearchParams(window.location.search);
585
+ const tabsStr = params.get(tabsKey);
586
+ const activeTab = params.get(activeKey);
587
+ if (!tabsStr) return null;
588
+ const tabs = tabsStr.split(",").filter(Boolean);
589
+ return { tabs, activeTab };
590
+ },
591
+ write(tabs, activeTabId) {
592
+ if (typeof window === "undefined") return;
593
+ const params = new URLSearchParams(window.location.search);
594
+ if (tabs.length > 0) {
595
+ params.set(tabsKey, tabs.map((t) => t.instanceId).join(","));
596
+ if (activeTabId) {
597
+ params.set(activeKey, activeTabId);
598
+ } else {
599
+ params.delete(activeKey);
600
+ }
601
+ } else {
602
+ params.delete(tabsKey);
603
+ params.delete(activeKey);
604
+ }
605
+ const qs = params.toString().replace(/%2C/g, ",");
606
+ const newUrl = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
607
+ window.history.replaceState(null, "", newUrl);
608
+ },
609
+ subscribe(callback) {
610
+ window.addEventListener("popstate", callback);
611
+ return () => window.removeEventListener("popstate", callback);
612
+ }
613
+ };
614
+ }
615
+
616
+ export { MultiTabProvider, TabCloseButton, TabContent, TabList, TabPanel, TabPanels, TabTrigger, createPageRegistry, memoryAdapter, searchParamsAdapter, useMultiTab, useTabData, useTabInstanceId };
617
+ //# sourceMappingURL=index.mjs.map
618
+ //# sourceMappingURL=index.mjs.map