Skip to content

Commit 6db37b4

Browse files
committed
add second approach how to manage tabs
1 parent 83df180 commit 6db37b4

File tree

10 files changed

+365
-76
lines changed

10 files changed

+365
-76
lines changed

src/constants/routes.constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@ export const productsRoute = `${basePath}/products`;
55
export const productsListRoute = `${basePath}/products/`;
66
export const productDetailRoute = `${basePath}/products/:id`;
77
export const categoriesRoute = `${basePath}/categories`;
8+
export const categoriesListRoute = `${basePath}/categories/`;
9+
export const categoryDetailRoute = `${basePath}/categories/:id`;
810
export const suppliersRoute = `${basePath}/suppliers`;

src/constants/tabs.constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export enum TabStoreKey {
22
Main = "main",
33
Products = "products",
4+
Categories = "categories",
45
}

src/lib/tabs/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./tabbed-navigation";
22
export * from "./tabs.types.ts";
3+
export * from "./tabs.utils.ts";

src/lib/tabs/tabbed-navigation-2.tsx

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { useDataRouterContext } from "../../hooks/useDataRouterContext.tsx";
2+
import { useMatches, useNavigate } from "react-router-dom";
3+
import { useCallback, useEffect, useState } from "react";
4+
import { RouterState, AgnosticDataRouteMatch } from "@remix-run/router";
5+
import { last, replaceAt, insertAt } from "src/utils/array-utils.ts";
6+
import { TabModel, ValidTabMeta } from "src/lib/tabs/tabs.types.ts";
7+
import { pathToLocation } from "src/lib/tabs/tabs.utils.ts";
8+
9+
export type TabbedNavigationMeta = {
10+
path: string;
11+
routeId: string;
12+
};
13+
14+
type ValidParams = Record<string, unknown>;
15+
16+
type ValidID<Params extends ValidParams = ValidParams> =
17+
| string
18+
| { (props: { params: Params }): string };
19+
20+
type TabConfig<
21+
Params extends ValidParams = ValidParams,
22+
ID extends ValidID<Params> = ValidID<Params>,
23+
> = {
24+
routeId: string;
25+
id: ID;
26+
title: ({ params }: { params: Params }) => string;
27+
};
28+
29+
export class Tab<
30+
Params extends ValidParams = ValidParams,
31+
ID extends ValidID<Params> = ValidID<Params>,
32+
> {
33+
routeId: string;
34+
id: ID;
35+
title: ({ params }: { params: Params }) => string;
36+
constructor(props: TabConfig<Params, ID>) {
37+
const { id, routeId, title } = props;
38+
this.id = id;
39+
this.routeId = routeId;
40+
this.title = title;
41+
}
42+
}
43+
44+
export const useTabbedNavigation2 = <
45+
Meta extends ValidTabMeta = ValidTabMeta,
46+
Params extends ValidParams = ValidParams,
47+
>(options: {
48+
config: Tab<Params>[];
49+
initialTabs?: TabModel<TabbedNavigationMeta & Meta>[];
50+
initialStartPinnedTabs?: string[];
51+
onCloseAllTabs: () => void;
52+
resolveTabMeta: (match: AgnosticDataRouteMatch) => Meta;
53+
}) => {
54+
const {
55+
resolveTabMeta,
56+
onCloseAllTabs,
57+
initialTabs = [],
58+
initialStartPinnedTabs = [],
59+
config,
60+
} = options;
61+
const [tabs, setTabs] =
62+
useState<TabModel<TabbedNavigationMeta & Meta>[]>(initialTabs);
63+
const [startPinnedTabs, setStartPinnedTabs] = useState<string[]>(
64+
initialStartPinnedTabs,
65+
);
66+
const { router } = useDataRouterContext();
67+
const navigate = useNavigate();
68+
69+
const setActiveTabId = (id: string | undefined) => {
70+
const tab = tabs.find((tab) => tab.id === id);
71+
if (tab) {
72+
navigate(pathToLocation(tab.meta.path));
73+
} else {
74+
onCloseAllTabs?.();
75+
}
76+
};
77+
const updateTabs = useCallback(
78+
(state: RouterState) => {
79+
const { matches, location, navigation } = state;
80+
81+
if (navigation.location) {
82+
return;
83+
}
84+
85+
const pairs = matches
86+
.slice()
87+
.reverse()
88+
.map((match) => {
89+
const def = config.find(
90+
(tabDef) => tabDef.routeId === match.route.id,
91+
);
92+
return [def, match];
93+
})
94+
.filter(([def]) => !!def) as unknown as [Tab, AgnosticDataRouteMatch][];
95+
96+
pairs.forEach(([def, match]) => {
97+
setTabs((prevTabs) => {
98+
const tab = prevTabs.find(
99+
(tab) =>
100+
tab.id ===
101+
(typeof def.id === "function" ? def.id(match) : def.id),
102+
);
103+
104+
const { pathname } = last(matches);
105+
const { search } = location;
106+
const path = pathname + (search ? `${search}` : "");
107+
108+
if (tab) {
109+
// update the tab path
110+
const index = prevTabs.indexOf(tab);
111+
112+
return replaceAt(prevTabs, index, {
113+
...tab,
114+
meta: {
115+
...tab.meta,
116+
path,
117+
...resolveTabMeta(match),
118+
},
119+
});
120+
} else {
121+
const newTab: TabModel<TabbedNavigationMeta & Meta> = {
122+
id: typeof def.id === "function" ? def.id(match) : def.id,
123+
title: def.title(match),
124+
meta: {
125+
path,
126+
routeId: match.route.id,
127+
...resolveTabMeta(match),
128+
},
129+
};
130+
131+
// prepend a new tab
132+
return insertAt(prevTabs, startPinnedTabs.length, newTab);
133+
}
134+
});
135+
});
136+
},
137+
[resolveTabMeta, startPinnedTabs, config],
138+
);
139+
140+
useEffect(() => {
141+
// fire immediately
142+
updateTabs(router.state);
143+
return router.subscribe(updateTabs);
144+
}, [router, updateTabs]);
145+
146+
const matches = useMatches();
147+
148+
const activeTabId = matches
149+
.slice()
150+
.reverse()
151+
.find((match) => {
152+
return config.find((def) => def.routeId === match.id);
153+
})?.pathname;
154+
155+
return {
156+
getTabsProps: () => ({
157+
activeTabId,
158+
tabs,
159+
onTabsChange: setTabs,
160+
onActiveTabIdChange: setActiveTabId,
161+
hasControlledActiveTabId: true,
162+
startPinnedTabs,
163+
onStartPinnedTabsChange: setStartPinnedTabs,
164+
}),
165+
setTabs,
166+
setActiveTabId,
167+
tabs,
168+
activeTabId,
169+
};
170+
};

src/lib/tabs/tabbed-navigation.tsx

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useState } from "react";
1010
import { RouterState, AgnosticDataRouteMatch } from "@remix-run/router";
1111
import { last, replaceAt, insertAt } from "src/utils/array-utils.ts";
1212
import { TabModel, ValidTabMeta } from "src/lib/tabs/tabs.types.ts";
13+
import { pathToLocation } from "src/lib/tabs/tabs.utils.ts";
1314

1415
export type TabbedNavigationMeta = {
1516
path: string;
@@ -32,25 +33,6 @@ export const getTabHandleUI =
3233
);
3334
};
3435

35-
export function closestItem<T>(arr: T[], item: T): T | undefined {
36-
const index = arr.indexOf(item);
37-
if (index === -1) {
38-
return arr[0];
39-
} else if (index === arr.length - 1) {
40-
return arr[arr.length - 2];
41-
} else {
42-
return arr[index + 1];
43-
}
44-
}
45-
46-
export const pathToLocation = (path: string) => {
47-
const [pathname, search] = path.split("?");
48-
return {
49-
pathname,
50-
search,
51-
};
52-
};
53-
5436
export const useActiveTabId = (key: string) => {
5537
const matches = useMatches() as UIMatch<any, Handle>[];
5638
const storeMatches = matches.filter(getTabHandleUI(key));

src/lib/tabs/tabs.types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,5 @@ export type ValidTabMeta = Record<string, unknown>;
33
export type TabModel<Meta extends ValidTabMeta = ValidTabMeta> = {
44
id: string;
55
title: string;
6-
key: string;
76
meta: Meta;
87
};

src/lib/tabs/tabs.utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,22 @@ export const flattenRoutes = (routes: RouteObject[]): RouteObject[] => {
55
return [...acc, route, ...flattenRoutes(route.children || [])];
66
}, [] as RouteObject[]);
77
};
8+
9+
export function closestItem<T>(arr: T[], item: T): T | undefined {
10+
const index = arr.indexOf(item);
11+
if (index === -1) {
12+
return arr[0];
13+
} else if (index === arr.length - 1) {
14+
return arr[arr.length - 2];
15+
} else {
16+
return arr[index + 1];
17+
}
18+
}
19+
20+
export const pathToLocation = (path: string) => {
21+
const [pathname, search] = path.split("?");
22+
return {
23+
pathname,
24+
search,
25+
};
26+
};

0 commit comments

Comments
 (0)