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