Skip to content

Commit 6e3c73a

Browse files
tabs: persist tabs in url (#1517)
1 parent aa025f9 commit 6e3c73a

6 files changed

Lines changed: 248 additions & 3 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* Add: Selected Tabs and Page Tabs are reflected in the URL

znai-reactjs/src/doc-elements/page/page-tabs/PageTabsPageContent.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import PageTabsSelection from "./PageTabsSelection";
2020
import { buildContentForTab } from "./pageTabsContentUtils";
2121
import { findParentWithScroll } from "../../../utils/domNodes";
2222
import { tabsRegistration, TabSwitchEvent } from "../../tabs/TabsRegistration";
23+
import { readPageTabIdFromQuery, writePageTabIdToQuery } from "../../tabs/tabsQueryParams";
2324

2425
interface ScrollSnapshot {
2526
parentWithScroll: HTMLElement;
@@ -38,12 +39,14 @@ class PageTabsPageContent extends React.Component<any, PageTabsState> {
3839
super(props);
3940

4041
const { tabIds } = props;
41-
this.state = { activeTabId: tabsRegistration.firstMatchFromHistory(tabIds) || "" };
42+
const tabIdFromQuery = readPageTabIdFromQuery(tabIds);
43+
this.state = { activeTabId: tabIdFromQuery ?? tabsRegistration.firstMatchFromHistory(tabIds) ?? "" };
4244
this.contentRef = React.createRef();
4345
}
4446

4547
componentDidMount() {
4648
tabsRegistration.addTabSwitchListener(this.onTabSwitch);
49+
this.syncActiveTabToQuery();
4750
}
4851

4952
componentWillUnmount() {
@@ -86,6 +89,15 @@ class PageTabsPageContent extends React.Component<any, PageTabsState> {
8689
const { tabIds } = this.props;
8790
if (tabIds.includes(tabName) && tabName !== this.state.activeTabId) {
8891
this.setState({ activeTabId: tabName });
92+
writePageTabIdToQuery(tabName);
93+
}
94+
};
95+
96+
syncActiveTabToQuery = () => {
97+
const { tabIds } = this.props;
98+
const activeTabId = tabIds.includes(this.state.activeTabId) ? this.state.activeTabId : tabIds[0];
99+
if (activeTabId) {
100+
writePageTabIdToQuery(activeTabId);
89101
}
90102
};
91103

znai-reactjs/src/doc-elements/tabs/Tabs.jsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import React from "react";
1919

2020
import { tabsRegistration } from "./TabsRegistration";
21+
import { readTabIdFromQuery, writeTabIdToQuery } from "./tabsQueryParams";
2122
import { findParentWithScroll } from "../../utils/domNodes";
2223

2324
import "./Tabs.css";
@@ -45,7 +46,7 @@ class Tabs extends React.Component {
4546
const {tabsContent, forcedTabIdx, defaultTabIdx} = this.props
4647
const names = tabsContent.map(t => t.name)
4748

48-
const tabName = tabsRegistration.firstMatchFromHistory(names);
49+
const tabName = readTabIdFromQuery(names) ?? tabsRegistration.firstMatchFromHistory(names);
4950

5051
const idx = typeof forcedTabIdx !== 'undefined' ?
5152
forcedTabIdx:
@@ -59,6 +60,7 @@ class Tabs extends React.Component {
5960

6061
componentDidMount() {
6162
tabsRegistration.addTabSwitchListener(this.onTabSwitch)
63+
this.syncActiveTabToQuery()
6264
}
6365

6466
componentWillUnmount() {
@@ -137,13 +139,28 @@ class Tabs extends React.Component {
137139
}
138140

139141
onTabSwitch = ({tabName, triggeredNode}) => {
140-
const {tabsContent} = this.props
142+
const {tabsContent, forcedTabIdx} = this.props
141143
const names = tabsContent.map(t => t.name)
142144

143145
const idx = names.indexOf(tabName)
144146
if (idx !== -1) {
145147
this.setState({activeIdx: idx, triggeredNode})
148+
if (typeof forcedTabIdx === 'undefined') {
149+
writeTabIdToQuery(names, tabName)
150+
}
151+
}
152+
}
153+
154+
syncActiveTabToQuery = () => {
155+
const {tabsContent, forcedTabIdx} = this.props
156+
if (typeof forcedTabIdx !== 'undefined') {
157+
return
158+
}
159+
const activeTab = tabsContent[this.state.activeIdx]
160+
if (!activeTab) {
161+
return
146162
}
163+
writeTabIdToQuery(tabsContent.map(t => t.name), activeTab.name)
147164
}
148165

149166
getSnapshotBeforeUpdate(prevProps, prevState) {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* Copyright 2026 znai maintainers
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
readPageTabIdFromQuery,
19+
readTabIdFromQuery,
20+
writePageTabIdToQuery,
21+
writeTabIdToQuery,
22+
} from "./tabsQueryParams";
23+
24+
function setUrl(search: string) {
25+
window.history.replaceState(null, "", "/page" + search);
26+
}
27+
28+
beforeEach(() => {
29+
setUrl("");
30+
});
31+
32+
describe("readTabIdFromQuery", () => {
33+
it("returns null when no tabId or no value matches this set", () => {
34+
expect(readTabIdFromQuery(["Python", "Java"])).toBeNull();
35+
36+
setUrl("?tabId=Mac");
37+
expect(readTabIdFromQuery(["Python", "Java"])).toBeNull();
38+
});
39+
40+
it("returns the first value from the comma list that matches this set", () => {
41+
setUrl("?tabId=Python");
42+
expect(readTabIdFromQuery(["Python", "Java"])).toEqual("Python");
43+
44+
setUrl("?tabId=Mac,Java,Linux");
45+
expect(readTabIdFromQuery(["Python", "Java"])).toEqual("Java");
46+
});
47+
48+
it("tolerates percent-encoded commas and surrounding whitespace", () => {
49+
setUrl("?tabId=Mac%2CJava");
50+
expect(readTabIdFromQuery(["Python", "Java"])).toEqual("Java");
51+
52+
setUrl("?tabId=Mac , Java");
53+
expect(readTabIdFromQuery(["Python", "Java"])).toEqual("Java");
54+
});
55+
});
56+
57+
describe("writeTabIdToQuery", () => {
58+
it("sets tabId when none exists", () => {
59+
writeTabIdToQuery(["Python", "Java"], "Python");
60+
expect(window.location.search).toEqual("?tabId=Python");
61+
});
62+
63+
it("appends when prior value belongs to a different set", () => {
64+
setUrl("?tabId=Mac");
65+
writeTabIdToQuery(["Python", "Java"], "Python");
66+
expect(window.location.search).toEqual("?tabId=Mac,Python");
67+
});
68+
69+
it("replaces only the slot belonging to this set in a multi value list", () => {
70+
setUrl("?tabId=Mac,Python,Blue");
71+
writeTabIdToQuery(["Python", "Java"], "Java");
72+
expect(window.location.search).toEqual("?tabId=Mac,Blue,Java");
73+
});
74+
75+
it("removes all prior matches for this set before appending", () => {
76+
setUrl("?tabId=Python,Mac,Java");
77+
writeTabIdToQuery(["Python", "Java"], "Java");
78+
expect(window.location.search).toEqual("?tabId=Mac,Java");
79+
});
80+
81+
it("preserves unrelated query parameters", () => {
82+
setUrl("?office=NYC&tabId=Mac");
83+
writeTabIdToQuery(["Python", "Java"], "Python");
84+
expect(window.location.search).toEqual("?office=NYC&tabId=Mac,Python");
85+
});
86+
});
87+
88+
describe("readPageTabIdFromQuery", () => {
89+
it("returns the value when it matches the list, null otherwise", () => {
90+
expect(readPageTabIdFromQuery(["intro", "advanced"])).toBeNull();
91+
92+
setUrl("?pageTabId=advanced");
93+
expect(readPageTabIdFromQuery(["intro", "advanced"])).toEqual("advanced");
94+
95+
setUrl("?pageTabId=unknown");
96+
expect(readPageTabIdFromQuery(["intro", "advanced"])).toBeNull();
97+
});
98+
});
99+
100+
describe("writePageTabIdToQuery", () => {
101+
it("sets or overwrites pageTabId", () => {
102+
writePageTabIdToQuery("intro");
103+
expect(window.location.search).toEqual("?pageTabId=intro");
104+
105+
writePageTabIdToQuery("advanced");
106+
expect(window.location.search).toEqual("?pageTabId=advanced");
107+
});
108+
109+
it("preserves tabId and other query parameters", () => {
110+
setUrl("?tabId=Python&office=NYC");
111+
writePageTabIdToQuery("advanced");
112+
expect(window.location.search).toEqual("?tabId=Python&office=NYC&pageTabId=advanced");
113+
});
114+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright 2026 znai maintainers
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
const TAB_ID_PARAM = "tabId";
18+
const PAGE_TAB_ID_PARAM = "pageTabId";
19+
20+
// tabNames are the identifiers a tab set uses to mark its tabs; they are what gets
21+
// serialized under the tabId / pageTabId URL query params. regular Tabs components
22+
// call these `name` internally (Tab.name field); page tabs call them `tabId` internally
23+
// but pass them as tabNames at this boundary.
24+
//
25+
// the tabId query param is a comma separated list because multiple independent
26+
// Tabs components may live on the same page, each contributing one value.
27+
28+
// returns the first value from the URL's tabId comma list that belongs to this
29+
// tab set, or null if the URL has no value for this set. callers fall back to
30+
// their normal selection logic (history, defaults) when null is returned.
31+
// iteration order follows the URL, not tabNames, so a shared link keeps the
32+
// author's intended ordering.
33+
export function readTabIdFromQuery(tabNames: string[]): string | null {
34+
const parts = splitValues(currentParams().get(TAB_ID_PARAM));
35+
for (const p of parts) {
36+
if (tabNames.indexOf(p) >= 0) {
37+
return p;
38+
}
39+
}
40+
return null;
41+
}
42+
43+
export function writeTabIdToQuery(tabNames: string[], selected: string): void {
44+
const params = currentParams();
45+
const existing = splitValues(params.get(TAB_ID_PARAM));
46+
const kept = existing.filter((v) => tabNames.indexOf(v) < 0);
47+
kept.push(selected);
48+
params.set(TAB_ID_PARAM, kept.join(","));
49+
replaceUrl(params);
50+
}
51+
52+
export function readPageTabIdFromQuery(tabNames: string[]): string | null {
53+
const value = currentParams().get(PAGE_TAB_ID_PARAM);
54+
if (!value) {
55+
return null;
56+
}
57+
return tabNames.indexOf(value) >= 0 ? value : null;
58+
}
59+
60+
export function writePageTabIdToQuery(tabId: string): void {
61+
const params = currentParams();
62+
params.set(PAGE_TAB_ID_PARAM, tabId);
63+
replaceUrl(params);
64+
}
65+
66+
function currentParams(): URLSearchParams {
67+
return new URLSearchParams(window.location.search);
68+
}
69+
70+
function replaceUrl(params: URLSearchParams): void {
71+
// URLSearchParams percent-encodes commas; keep them readable for shareable URLs
72+
const search = params.toString().replace(/%2C/g, ",");
73+
const url = window.location.pathname + (search ? "?" + search : "") + window.location.hash;
74+
window.history.replaceState(null, "", url);
75+
}
76+
77+
function splitValues(raw: string | null): string[] {
78+
if (!raw) {
79+
return [];
80+
}
81+
return raw
82+
.split(",")
83+
.map((s) => s.trim())
84+
.filter((s) => s.length > 0);
85+
}

znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,29 @@ export function extractHighlightParams(): HighlightParams | null {
4949
return null;
5050
}
5151

52+
const HIGHLIGHT_PARAMS = [
53+
HIGHLIGHT_PREFIX_PARAM,
54+
HIGHLIGHT_SELECTION_PARAM,
55+
HIGHLIGHT_SUFFIX_PARAM,
56+
HIGHLIGHT_QUESTION_PARAM,
57+
HIGHLIGHT_CONTEXT_PARAM,
58+
];
59+
5260
export function buildHighlightUrl(params: HighlightParams): string {
5361
let builtUrl = location.origin + location.pathname;
5462
if (!builtUrl.endsWith("/")) {
5563
builtUrl += "/";
5664
}
5765

5866
const url = new URL(builtUrl);
67+
// preserve existing query params
68+
new URLSearchParams(location.search).forEach((value, key) => {
69+
url.searchParams.set(key, value);
70+
});
71+
72+
// clear existing highlight params
73+
HIGHLIGHT_PARAMS.forEach((p) => url.searchParams.delete(p));
74+
5975
url.searchParams.set(HIGHLIGHT_PREFIX_PARAM, encodeURIComponent(params.prefix));
6076
url.searchParams.set(HIGHLIGHT_SELECTION_PARAM, encodeURIComponent(params.selection));
6177
url.searchParams.set(HIGHLIGHT_SUFFIX_PARAM, encodeURIComponent(params.suffix));

0 commit comments

Comments
 (0)