Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Add: Selected Tabs and Page Tabs are reflected in the URL
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import PageTabsSelection from "./PageTabsSelection";
import { buildContentForTab } from "./pageTabsContentUtils";
import { findParentWithScroll } from "../../../utils/domNodes";
import { tabsRegistration, TabSwitchEvent } from "../../tabs/TabsRegistration";
import { readPageTabIdFromQuery, writePageTabIdToQuery } from "../../tabs/tabsQueryParams";

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

const { tabIds } = props;
this.state = { activeTabId: tabsRegistration.firstMatchFromHistory(tabIds) || "" };
const tabIdFromQuery = readPageTabIdFromQuery(tabIds);
this.state = { activeTabId: tabIdFromQuery ?? tabsRegistration.firstMatchFromHistory(tabIds) ?? "" };
this.contentRef = React.createRef();
}

componentDidMount() {
tabsRegistration.addTabSwitchListener(this.onTabSwitch);
this.syncActiveTabToQuery();
}

componentWillUnmount() {
Expand Down Expand Up @@ -86,6 +89,15 @@ class PageTabsPageContent extends React.Component<any, PageTabsState> {
const { tabIds } = this.props;
if (tabIds.includes(tabName) && tabName !== this.state.activeTabId) {
this.setState({ activeTabId: tabName });
writePageTabIdToQuery(tabName);
}
};

syncActiveTabToQuery = () => {
const { tabIds } = this.props;
const activeTabId = tabIds.includes(this.state.activeTabId) ? this.state.activeTabId : tabIds[0];
if (activeTabId) {
writePageTabIdToQuery(activeTabId);
}
};

Expand Down
21 changes: 19 additions & 2 deletions znai-reactjs/src/doc-elements/tabs/Tabs.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import React from "react";

import { tabsRegistration } from "./TabsRegistration";
import { readTabIdFromQuery, writeTabIdToQuery } from "./tabsQueryParams";
import { findParentWithScroll } from "../../utils/domNodes";

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

const tabName = tabsRegistration.firstMatchFromHistory(names);
const tabName = readTabIdFromQuery(names) ?? tabsRegistration.firstMatchFromHistory(names);

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

componentDidMount() {
tabsRegistration.addTabSwitchListener(this.onTabSwitch)
this.syncActiveTabToQuery()
}

componentWillUnmount() {
Expand Down Expand Up @@ -137,13 +139,28 @@ class Tabs extends React.Component {
}

onTabSwitch = ({tabName, triggeredNode}) => {
const {tabsContent} = this.props
const {tabsContent, forcedTabIdx} = this.props
const names = tabsContent.map(t => t.name)

const idx = names.indexOf(tabName)
if (idx !== -1) {
this.setState({activeIdx: idx, triggeredNode})
if (typeof forcedTabIdx === 'undefined') {
writeTabIdToQuery(names, tabName)
}
}
}

syncActiveTabToQuery = () => {
const {tabsContent, forcedTabIdx} = this.props
if (typeof forcedTabIdx !== 'undefined') {
return
}
const activeTab = tabsContent[this.state.activeIdx]
if (!activeTab) {
return
}
writeTabIdToQuery(tabsContent.map(t => t.name), activeTab.name)
}

getSnapshotBeforeUpdate(prevProps, prevState) {
Expand Down
114 changes: 114 additions & 0 deletions znai-reactjs/src/doc-elements/tabs/tabsQueryParams.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Copyright 2026 znai maintainers
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {
readPageTabIdFromQuery,
readTabIdFromQuery,
writePageTabIdToQuery,
writeTabIdToQuery,
} from "./tabsQueryParams";

function setUrl(search: string) {
window.history.replaceState(null, "", "/page" + search);
}

beforeEach(() => {
setUrl("");
});

describe("readTabIdFromQuery", () => {
it("returns null when no tabId or no value matches this set", () => {
expect(readTabIdFromQuery(["Python", "Java"])).toBeNull();

setUrl("?tabId=Mac");
expect(readTabIdFromQuery(["Python", "Java"])).toBeNull();
});

it("returns the first value from the comma list that matches this set", () => {
setUrl("?tabId=Python");
expect(readTabIdFromQuery(["Python", "Java"])).toEqual("Python");

setUrl("?tabId=Mac,Java,Linux");
expect(readTabIdFromQuery(["Python", "Java"])).toEqual("Java");
});

it("tolerates percent-encoded commas and surrounding whitespace", () => {
setUrl("?tabId=Mac%2CJava");
expect(readTabIdFromQuery(["Python", "Java"])).toEqual("Java");

setUrl("?tabId=Mac , Java");
expect(readTabIdFromQuery(["Python", "Java"])).toEqual("Java");
});
});

describe("writeTabIdToQuery", () => {
it("sets tabId when none exists", () => {
writeTabIdToQuery(["Python", "Java"], "Python");
expect(window.location.search).toEqual("?tabId=Python");
});

it("appends when prior value belongs to a different set", () => {
setUrl("?tabId=Mac");
writeTabIdToQuery(["Python", "Java"], "Python");
expect(window.location.search).toEqual("?tabId=Mac,Python");
});

it("replaces only the slot belonging to this set in a multi value list", () => {
setUrl("?tabId=Mac,Python,Blue");
writeTabIdToQuery(["Python", "Java"], "Java");
expect(window.location.search).toEqual("?tabId=Mac,Blue,Java");
});

it("removes all prior matches for this set before appending", () => {
setUrl("?tabId=Python,Mac,Java");
writeTabIdToQuery(["Python", "Java"], "Java");
expect(window.location.search).toEqual("?tabId=Mac,Java");
});

it("preserves unrelated query parameters", () => {
setUrl("?office=NYC&tabId=Mac");
writeTabIdToQuery(["Python", "Java"], "Python");
expect(window.location.search).toEqual("?office=NYC&tabId=Mac,Python");
});
});

describe("readPageTabIdFromQuery", () => {
it("returns the value when it matches the list, null otherwise", () => {
expect(readPageTabIdFromQuery(["intro", "advanced"])).toBeNull();

setUrl("?pageTabId=advanced");
expect(readPageTabIdFromQuery(["intro", "advanced"])).toEqual("advanced");

setUrl("?pageTabId=unknown");
expect(readPageTabIdFromQuery(["intro", "advanced"])).toBeNull();
});
});

describe("writePageTabIdToQuery", () => {
it("sets or overwrites pageTabId", () => {
writePageTabIdToQuery("intro");
expect(window.location.search).toEqual("?pageTabId=intro");

writePageTabIdToQuery("advanced");
expect(window.location.search).toEqual("?pageTabId=advanced");
});

it("preserves tabId and other query parameters", () => {
setUrl("?tabId=Python&office=NYC");
writePageTabIdToQuery("advanced");
expect(window.location.search).toEqual("?tabId=Python&office=NYC&pageTabId=advanced");
});
});
85 changes: 85 additions & 0 deletions znai-reactjs/src/doc-elements/tabs/tabsQueryParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Copyright 2026 znai maintainers
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

const TAB_ID_PARAM = "tabId";
const PAGE_TAB_ID_PARAM = "pageTabId";

// tabNames are the identifiers a tab set uses to mark its tabs; they are what gets
// serialized under the tabId / pageTabId URL query params. regular Tabs components
// call these `name` internally (Tab.name field); page tabs call them `tabId` internally
// but pass them as tabNames at this boundary.
//
// the tabId query param is a comma separated list because multiple independent
// Tabs components may live on the same page, each contributing one value.

// returns the first value from the URL's tabId comma list that belongs to this
// tab set, or null if the URL has no value for this set. callers fall back to
// their normal selection logic (history, defaults) when null is returned.
// iteration order follows the URL, not tabNames, so a shared link keeps the
// author's intended ordering.
export function readTabIdFromQuery(tabNames: string[]): string | null {
const parts = splitValues(currentParams().get(TAB_ID_PARAM));
for (const p of parts) {
if (tabNames.indexOf(p) >= 0) {
return p;
}
}
return null;
}

export function writeTabIdToQuery(tabNames: string[], selected: string): void {
const params = currentParams();
const existing = splitValues(params.get(TAB_ID_PARAM));
const kept = existing.filter((v) => tabNames.indexOf(v) < 0);
kept.push(selected);
params.set(TAB_ID_PARAM, kept.join(","));
replaceUrl(params);
}

export function readPageTabIdFromQuery(tabNames: string[]): string | null {
const value = currentParams().get(PAGE_TAB_ID_PARAM);
if (!value) {
return null;
}
return tabNames.indexOf(value) >= 0 ? value : null;
}

export function writePageTabIdToQuery(tabId: string): void {
const params = currentParams();
params.set(PAGE_TAB_ID_PARAM, tabId);
replaceUrl(params);
}

function currentParams(): URLSearchParams {
return new URLSearchParams(window.location.search);
}

function replaceUrl(params: URLSearchParams): void {
// URLSearchParams percent-encodes commas; keep them readable for shareable URLs
const search = params.toString().replace(/%2C/g, ",");
const url = window.location.pathname + (search ? "?" + search : "") + window.location.hash;
window.history.replaceState(null, "", url);
}

function splitValues(raw: string | null): string[] {
if (!raw) {
return [];
}
return raw
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
16 changes: 16 additions & 0 deletions znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,29 @@ export function extractHighlightParams(): HighlightParams | null {
return null;
}

const HIGHLIGHT_PARAMS = [
HIGHLIGHT_PREFIX_PARAM,
HIGHLIGHT_SELECTION_PARAM,
HIGHLIGHT_SUFFIX_PARAM,
HIGHLIGHT_QUESTION_PARAM,
HIGHLIGHT_CONTEXT_PARAM,
];

export function buildHighlightUrl(params: HighlightParams): string {
let builtUrl = location.origin + location.pathname;
if (!builtUrl.endsWith("/")) {
builtUrl += "/";
}

const url = new URL(builtUrl);
// preserve existing query params
new URLSearchParams(location.search).forEach((value, key) => {
url.searchParams.set(key, value);
});

// clear existing highlight params
HIGHLIGHT_PARAMS.forEach((p) => url.searchParams.delete(p));

url.searchParams.set(HIGHLIGHT_PREFIX_PARAM, encodeURIComponent(params.prefix));
url.searchParams.set(HIGHLIGHT_SELECTION_PARAM, encodeURIComponent(params.selection));
url.searchParams.set(HIGHLIGHT_SUFFIX_PARAM, encodeURIComponent(params.suffix));
Expand Down
Loading