diff --git a/znai-docs/znai/release-notes/1.90/add-2026-04-23-tabs-query-params.md b/znai-docs/znai/release-notes/1.90/add-2026-04-23-tabs-query-params.md new file mode 100644 index 000000000..279e19b99 --- /dev/null +++ b/znai-docs/znai/release-notes/1.90/add-2026-04-23-tabs-query-params.md @@ -0,0 +1 @@ +* Add: Selected Tabs and Page Tabs are reflected in the URL \ No newline at end of file diff --git a/znai-reactjs/src/doc-elements/page/page-tabs/PageTabsPageContent.tsx b/znai-reactjs/src/doc-elements/page/page-tabs/PageTabsPageContent.tsx index fa5fa91c6..005b5593f 100644 --- a/znai-reactjs/src/doc-elements/page/page-tabs/PageTabsPageContent.tsx +++ b/znai-reactjs/src/doc-elements/page/page-tabs/PageTabsPageContent.tsx @@ -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; @@ -38,12 +39,14 @@ class PageTabsPageContent extends React.Component { 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() { @@ -86,6 +89,15 @@ class PageTabsPageContent extends React.Component { 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); } }; diff --git a/znai-reactjs/src/doc-elements/tabs/Tabs.jsx b/znai-reactjs/src/doc-elements/tabs/Tabs.jsx index 73e35184c..f4cc3fa04 100644 --- a/znai-reactjs/src/doc-elements/tabs/Tabs.jsx +++ b/znai-reactjs/src/doc-elements/tabs/Tabs.jsx @@ -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"; @@ -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: @@ -59,6 +60,7 @@ class Tabs extends React.Component { componentDidMount() { tabsRegistration.addTabSwitchListener(this.onTabSwitch) + this.syncActiveTabToQuery() } componentWillUnmount() { @@ -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) { diff --git a/znai-reactjs/src/doc-elements/tabs/tabsQueryParams.test.ts b/znai-reactjs/src/doc-elements/tabs/tabsQueryParams.test.ts new file mode 100644 index 000000000..0d00e39a0 --- /dev/null +++ b/znai-reactjs/src/doc-elements/tabs/tabsQueryParams.test.ts @@ -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"); + }); +}); diff --git a/znai-reactjs/src/doc-elements/tabs/tabsQueryParams.ts b/znai-reactjs/src/doc-elements/tabs/tabsQueryParams.ts new file mode 100644 index 000000000..414c27082 --- /dev/null +++ b/znai-reactjs/src/doc-elements/tabs/tabsQueryParams.ts @@ -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); +} diff --git a/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts b/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts index a287ae067..cdb017ac2 100644 --- a/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts +++ b/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts @@ -49,6 +49,14 @@ 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("/")) { @@ -56,6 +64,14 @@ export function buildHighlightUrl(params: HighlightParams): string { } 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));