From 172f50c5d5ac3a4801b398fbf813b49bf5355475 Mon Sep 17 00:00:00 2001 From: Iisakki Rotko Date: Mon, 14 Oct 2024 16:21:06 +0200 Subject: [PATCH] feat: enable navigation to hashes Previously this was not officially supported, and would only work when moving within a page, or when initially loading a page with SSG enabled. --- solara/server/static/main-vuetify.js | 12 ++++- .../content/20-understanding/40-routing.md | 10 ++++ solara/widgets/vue/navigator.vue | 54 ++++++++++++++++--- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/solara/server/static/main-vuetify.js b/solara/server/static/main-vuetify.js index 564240471..b30b1fa6f 100644 --- a/solara/server/static/main-vuetify.js +++ b/solara/server/static/main-vuetify.js @@ -127,7 +127,7 @@ async function solaraInit(mountId, appName) { window.navigator.sendBeacon(close_url); } }); - let kernel = await solara.connectKernel(solara.jupyterRootPath, kernelId) + let kernel = await solara.connectKernel(solara.rootPath + '/jupyter', kernelId) if (!kernel) { return; } @@ -138,15 +138,25 @@ async function solaraInit(mountId, appName) { }); window.addEventListener('solara.router', function (event) { + app.$data.urlHasChanged = true; if(kernel.status == 'busy') { app.$data.loadingPage = true; } }); kernel.statusChanged.connect(() => { + // When navigation is triggered from the front-end, kernel.status becoming busy and + // solara.router event happen in a different order than when navigating through Python, so + // if the URL has changed when the kernel becomes busy, we set loadingPage to true + if (kernel.status == 'busy' && app.$data.urlHasChanged) { + app.$data.loadingPage = true; + } // the first idle after a loadingPage == true (a router event) // will be used as indicator that the page is loaded if (app.$data.loadingPage && kernel.status == 'idle') { app.$data.loadingPage = false; + app.$data.urlHasChanged = false; + const event = new Event('solara.pageReady'); + window.dispatchEvent(event); } }); diff --git a/solara/website/pages/documentation/advanced/content/20-understanding/40-routing.md b/solara/website/pages/documentation/advanced/content/20-understanding/40-routing.md index 9859ca142..a3ec65cb8 100644 --- a/solara/website/pages/documentation/advanced/content/20-understanding/40-routing.md +++ b/solara/website/pages/documentation/advanced/content/20-understanding/40-routing.md @@ -219,6 +219,16 @@ def LinkToIpywidgets(): return main ``` +### Linking to Sections of a Page + +The `solara.Link` component also supports linking to HTML elements identified by id. Although most Solara components don't directly support the id attribute, you can assign ids to all ipyvuetify components, using the `attributes` argument: + +```python +solara.v.Btn(attributes={"id": "my-id"}, ...) +``` + +You can then link to a particular element by appending `#` followed by its id to your link, i.e. `solara.Link(route_or_path="/page#my-id")`. + ## Fully manual routing If you want to do routing fully manually, you can use the [`solara.use_router`](/documentation/api/routing/use_router) hook, and use the `.path` attribute. diff --git a/solara/widgets/vue/navigator.vue b/solara/widgets/vue/navigator.vue index 8c0c84f9e..dad9fe02a 100644 --- a/solara/widgets/vue/navigator.vue +++ b/solara/widgets/vue/navigator.vue @@ -14,31 +14,33 @@ modules.export = { } window.solara.router.push = (href) => { console.log("external router push", href); - // take of the anchor - if (href.indexOf("#") !== -1) { - href = href.slice(0, href.indexOf("#")); - } - this.location = href; + const url = new URL(href, window.location.origin + solara.rootPath); + this.location = url.pathname + url.search; + this.hash = url.hash; }; let location = window.location.pathname.slice(solara.rootPath.length); this.location = location + window.location.search; + this.hash = window.location.hash; window.addEventListener("popstate", this.onPopState); window.addEventListener("scroll", this.onScroll); + window.addEventListener("hashchange", this.onHashChange); + window.addEventListener("solara.pageReady", this.onPageLoad); }, destroyed() { window.removeEventListener("popstate", this.onPopState); window.removeEventListener("scroll", this.onScroll); + window.removeEventListener("hashchange", this.onHashChange); + window.removeEventListener("solara.pageReady", this.onPageLoad); }, methods: { onScroll() { window.history.replaceState( { top: document.documentElement.scrollTop }, null, - solara.rootPath + this.location + this.makeFullRelativeUrl() ); }, onPopState(event) { - console.log("pop state!", event.state, window.location.href); if (!window.location.pathname.startsWith(solara.rootPath)) { throw `window.location.pathname = ${window.location.pathname}, but it should start with the solara.rootPath = ${solara.rootPath}`; } @@ -55,6 +57,32 @@ modules.export = { */ } }, + onHashChange(event) { + if (!window.location.pathname.startsWith(solara.rootPath)) { + throw `window.location.pathname = ${window.location.pathname}, but it should start with the solara.rootPath = ${solara.rootPath}`; + } + this.hash = window.location.hash; + }, + onPageLoad(event) { + if (!window.location.pathname.startsWith(solara.rootPath)) { + throw `window.location.pathname = ${window.location.pathname}, but it should start with the solara.rootPath = ${solara.rootPath}`; + } + // If we've navigated to a hash with the same name on a different page the watch on hash won't trigger + if (this.hash && this.hash === window.location.hash) { + this.navigateToHash(this.hash); + } + this.hash = window.location.hash; + }, + makeFullRelativeUrl() { + const url = new URL(this.location, window.location.origin + solara.rootPath); + return url.pathname + this.hash + url.search; + }, + navigateToHash(hash) { + const targetEl = document.getElementById(hash.slice(1)); + if (targetEl) { + targetEl.scrollIntoView(); + } + }, }, watch: { location(value) { @@ -81,7 +109,7 @@ modules.export = { document.documentElement.scrollTop ); if (oldLocation != this.location) { - window.history.pushState({ top: 0 }, null, solara.rootPath + this.location); + window.history.pushState({ top: 0 }, null, this.makeFullRelativeUrl()); if (pathnameNew != pathnameOld) { // we scroll to the top only when we change page, not when we change // the search string @@ -91,6 +119,16 @@ modules.export = { window.dispatchEvent(event); } }, + hash(value) { + if (value) { + this.navigateToHash(value); + } + }, + }, + data() { + return { + hash: "", + }; }, };