Skip to content

Commit d2f1a66

Browse files
committed
add controlled state
1 parent eaa8ebc commit d2f1a66

File tree

6 files changed

+1930
-1884
lines changed

6 files changed

+1930
-1884
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"name": "routable-tabs",
33
"private": true,
4+
"description": "Routable tabs",
45
"homepage": "https://layerok.github.io/react-router-tabs/",
56
"version": "0.0.0",
67
"type": "module",
@@ -26,7 +27,7 @@
2627
"eslint-config-prettier": "^9.1.0",
2728
"eslint-plugin-react-hooks": "^4.6.0",
2829
"eslint-plugin-react-refresh": "^0.4.6",
29-
"prettier": "3.2.5",
30+
"prettier": "^3.3.2",
3031
"typescript": "^5.2.2",
3132
"vite": "^5.2.0"
3233
}

src/components/AppLayout/AppLayout.tsx

Lines changed: 19 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -2,109 +2,44 @@ import "./AppLayout.css";
22

33
import { Outlet, useNavigate } from "react-router-dom";
44
import { Sidebar } from "src/components/Sidebar/Sidebar";
5-
import {Tabs, TabsApi} from "src/components/Tabs/Tabs.tsx";
5+
import { Tabs, TabsApi } from "src/components/Tabs/Tabs.tsx";
66

77
import { TabStoreKey } from "src/constants/tabs.constants.ts";
8-
import {getTabHandle, getTabLocation, TabModel} from "src/tabbed-navigation.tsx";
8+
import {
9+
pathToLocation,
10+
TabModel,
11+
useTabbedNavigation,
12+
} from "src/tabbed-navigation.tsx";
913
import * as routes from "src/constants/routes.constants.ts";
10-
import {useCallback, useEffect, useRef} from "react";
11-
import {RouterState} from "@remix-run/router";
12-
import {last, replaceAt} from "src/utils/array-utils.ts";
13-
import {uid} from "uid";
14-
import {useDataRouterContext} from "src/hooks/useDataRouterContext.tsx";
15-
import {Handle} from "src/router.tsx";
14+
import { useRef } from "react";
1615

1716
export function AppLayout() {
1817
const navigate = useNavigate();
1918

2019
const changeTab = (tab: TabModel | undefined) => {
21-
navigate(tab ? getTabLocation(tab) : routes.homeRoute);
20+
navigate(tab ? pathToLocation(tab.path) : routes.homeRoute);
2221
};
2322

24-
const { router } = useDataRouterContext();
25-
const storeKey = TabStoreKey.Main;
26-
const apiRef = useRef<TabsApi>()
27-
28-
const updateTabs = useCallback(
29-
(state: RouterState) => {
30-
const { matches, location, navigation } = state;
31-
32-
if (navigation.location) {
33-
return;
34-
}
35-
36-
const match = matches.find((match) => getTabHandle(match, storeKey));
37-
38-
if (!match) {
39-
return;
40-
}
41-
42-
const updateTabsState = (prevTabs: TabModel[]) => {
43-
const doesTabBelongToRouteMatch = (tab: TabModel) => {
44-
return (
45-
tab.routeId === match.route.id &&
46-
getTabLocation(tab).pathname.startsWith(match.pathname)
47-
);
48-
};
49-
50-
const tab = prevTabs.find(doesTabBelongToRouteMatch);
51-
52-
const path =
53-
last(matches).pathname +
54-
(location.search ? `${location.search}` : "");
55-
56-
if (tab) {
57-
// update the tab path
58-
const index = prevTabs.indexOf(tab);
59-
apiRef.current?.setActiveTabId(tab.id);
60-
return replaceAt(prevTabs, index, {
61-
...tab,
62-
path: path,
63-
});
64-
65-
}
66-
67-
const handle: Handle = (match.route?.handle);
68-
const title = handle.tabs[0]?.title?.(match);
69-
70-
const newTab = {
71-
storeKey: storeKey,
72-
id: uid(),
73-
title,
74-
path,
75-
routeId: match.route.id,
76-
}
77-
78-
apiRef.current?.setActiveTabId(newTab.id);
79-
80-
// prepend a new tab
81-
return [
82-
newTab,
83-
...prevTabs,
84-
];
85-
};
86-
87-
apiRef.current?.setTabs(updateTabsState)
88-
},
89-
[storeKey],
90-
);
91-
92-
useEffect(() => {
93-
// fire immediately
94-
updateTabs(router.state);
95-
return router.subscribe(updateTabs);
96-
}, [router, storeKey, updateTabs]);
23+
const apiRef = useRef<TabsApi>();
9724

25+
const { activeTabId, tabs, setTabs } = useTabbedNavigation(TabStoreKey.Main);
9826

9927
return (
10028
<div className="layout">
10129
<Sidebar />
10230
<div className={"content"}>
10331
<header className={"header"}>John Doe</header>
104-
<Tabs apiRef={apiRef} onActiveTabChange={changeTab} storeKey={TabStoreKey.Main} />
32+
<Tabs
33+
hasControlledActiveTabId
34+
tabs={tabs}
35+
apiRef={apiRef}
36+
activeTabId={activeTabId}
37+
onTabsChange={setTabs}
38+
onActiveTabChange={changeTab}
39+
storeKey={TabStoreKey.Main}
40+
/>
10541
<Outlet />
10642
</div>
10743
</div>
10844
);
10945
}
110-

src/components/Sidebar/Sidebar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import "./Sidebar.css";
22

33
import { Link } from "react-router-dom";
44
import * as routes from "src/constants/routes.constants.ts";
5+
import { memo } from "react";
56

6-
export function Sidebar() {
7+
export function RawSidebar() {
78
return (
89
<aside>
910
<nav className={"sidebar-menu"}>
@@ -14,3 +15,5 @@ export function Sidebar() {
1415
</aside>
1516
);
1617
}
18+
19+
export const Sidebar = memo(RawSidebar);

src/components/Tabs/Tabs.tsx

Lines changed: 99 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,156 @@
11
import "./Tabs.css";
2-
import React, {useImperativeHandle, useReducer} from "react";
2+
import React, { useEffect, useImperativeHandle, useReducer } from "react";
33
import { noop } from "src/utils/noop.ts";
4-
import { removeItem } from "src/utils/array-utils.ts";
5-
import {
6-
closestItem,
7-
TabModel,
8-
} from "src/tabbed-navigation.tsx";
4+
import { removeItem } from "src/utils/array-utils.ts";
5+
import { closestItem, TabModel } from "src/tabbed-navigation.tsx";
96
import { Tab } from "src/components/Tabs/Tab.tsx";
107

118
export type TabsApi = {
12-
setTabs: (tabs: TabModel[] | {(prevTabs: TabModel[]) :TabModel[]}) => void;
13-
setActiveTabId: (id: string) => void;
14-
}
9+
setTabs: (tabs: TabModel[] | { (prevTabs: TabModel[]): TabModel[] }) => void;
10+
setActiveTabId: (id: string) => void;
11+
getTabs: () => TabModel[];
12+
};
1513

1614
type TabsProps = {
15+
hasControlledActiveTabId?: boolean;
16+
activeTabId?: string;
17+
tabs?: TabModel[];
1718
storeKey: string;
1819
onActiveTabChange?: (tab: TabModel | undefined) => void;
20+
onTabsChange?: (tabs: TabModel[]) => void;
1921
apiRef?: React.Ref<TabsApi | undefined>;
2022
};
2123

2224
type State = {
2325
readonly tabs: TabModel[];
2426
readonly activeTabId: string | undefined;
25-
}
27+
};
2628

2729
type Action =
2830
| {
29-
type: 'close-tab'
30-
tab: TabModel
31-
} | {
32-
type: 'set-tabs'
33-
tabs: TabModel[]
34-
}| {
35-
type: 'set-active-tab-id'
36-
id: string;
37-
};
31+
type: "close-tab";
32+
tab: TabModel;
33+
onActiveTabChange?: (tab: TabModel | undefined) => void;
34+
onTabsChange?: (tab: TabModel[]) => void;
35+
}
36+
| {
37+
type: "set-tabs";
38+
tabs: TabModel[];
39+
onTabsChange?: (tab: TabModel[]) => void;
40+
}
41+
| {
42+
type: "set-active-tab-id";
43+
id: string;
44+
onActiveTabChange?: (tab: TabModel | undefined) => void;
45+
};
3846

3947
function reducer(state: State, action: Action): State {
4048
switch (action.type) {
41-
case 'close-tab': {
42-
const {tab} = action;
43-
const {tabs, activeTabId: prevActiveId, ...rest} = state;
49+
case "close-tab": {
50+
const { tab, onTabsChange, onActiveTabChange } = action;
51+
const { tabs, activeTabId: prevActiveId, ...rest } = state;
52+
53+
const prevActiveTab = tabs.find((tab) => tab.id === prevActiveId);
54+
const activeTab =
55+
prevActiveId === tab.id ? closestItem(tabs, tab) : prevActiveTab;
56+
57+
const updatedTabs = removeItem(tabs, tab);
58+
59+
onTabsChange?.(updatedTabs);
60+
onActiveTabChange?.(activeTab);
4461

45-
const activeTabId = prevActiveId === tab.id ? closestItem(tabs, tab)?.id: prevActiveId;
4662
return {
47-
tabs: removeItem(tabs, tab),
48-
activeTabId: activeTabId === tab.id ? closestItem(tabs, tab)?.id: activeTabId,
49-
...rest
50-
}
63+
tabs: updatedTabs,
64+
activeTabId: activeTab?.id,
65+
...rest,
66+
};
5167
}
52-
case 'set-tabs': {
53-
const {tabs} = action;
54-
68+
case "set-tabs": {
69+
const { tabs, onTabsChange } = action;
70+
onTabsChange?.(tabs);
5571
return {
5672
...state,
5773
tabs,
58-
}
74+
};
5975
}
60-
case 'set-active-tab-id': {
61-
const {id} = action;
62-
76+
case "set-active-tab-id": {
77+
const { id, onActiveTabChange } = action;
78+
const activeTab = state.tabs.find((tab) => tab.id === id);
79+
onActiveTabChange?.(activeTab);
6380
return {
6481
...state,
6582
activeTabId: id,
66-
}
83+
};
6784
}
6885
}
6986
}
7087

7188
export function Tabs(props: TabsProps) {
72-
const { onActiveTabChange = noop, apiRef } = props;
89+
const {
90+
onActiveTabChange = noop,
91+
apiRef,
92+
activeTabId: activeTabIdProp,
93+
hasControlledActiveTabId,
94+
tabs: tabsProp,
95+
onTabsChange,
96+
} = props;
7397

7498
const [state, dispatch] = useReducer(reducer, {
7599
tabs: [],
76100
activeTabId: undefined,
77101
});
78102

79-
useImperativeHandle(apiRef, () => ({
103+
useImperativeHandle<TabsApi>(apiRef, () => ({
80104
setTabs: (tabsArg) => {
81-
const tabs = typeof tabsArg === 'function' ? tabsArg(state.tabs): tabsArg;
105+
const tabs =
106+
typeof tabsArg === "function" ? tabsArg(state.tabs) : tabsArg;
82107
dispatch({
83-
type: 'set-tabs',
84-
tabs
85-
})
108+
type: "set-tabs",
109+
tabs,
110+
onTabsChange,
111+
});
86112
},
87113
setActiveTabId: (id: string) => {
88114
dispatch({
89-
type: 'set-active-tab-id',
90-
id
91-
})
92-
}
93-
}))
115+
type: "set-active-tab-id",
116+
id,
117+
onActiveTabChange,
118+
});
119+
},
120+
getTabs: () => state.tabs,
121+
}));
94122

95-
const {tabs, activeTabId} = state;
123+
const { tabs, activeTabId } = state;
96124

97-
const activeTab = tabs.find(tab => tab.id === activeTabId);
125+
const activeTab = tabs.find((tab) => tab.id === activeTabId);
98126

99127
const closeTab = (tab: TabModel) => {
100128
dispatch({
101-
type: 'close-tab',
102-
tab
103-
})
129+
type: "close-tab",
130+
tab,
131+
onTabsChange,
132+
onActiveTabChange,
133+
});
104134
};
105135

136+
useEffect(() => {
137+
if (hasControlledActiveTabId) {
138+
dispatch({
139+
type: "set-active-tab-id",
140+
id: activeTabIdProp,
141+
});
142+
}
143+
}, [hasControlledActiveTabId, activeTabIdProp]);
144+
145+
useEffect(() => {
146+
if (tabsProp) {
147+
dispatch({
148+
type: "set-tabs",
149+
tabs: tabsProp,
150+
});
151+
}
152+
}, [tabsProp]);
153+
106154
if (tabs.length < 1) {
107155
return null;
108156
}
@@ -121,4 +169,3 @@ export function Tabs(props: TabsProps) {
121169
</div>
122170
);
123171
}
124-

0 commit comments

Comments
 (0)