Skip to content

Synced tabs #462

@HiDeoo

Description

@HiDeoo

What version of starlight are you using?

0.6.1

What is your idea?

This proposal is a follow-up on a Discord discussion about the addition of synced tabs to Starlight.

Why is this feature necessary?

This features is pretty useful to show tabular data where the user will benefit from having the tabs synced. e.g.:

  • npm related commands for various package managers
  • code examples in different languages / frameworks
  • different versions of an API

Do you have examples of this feature in other projects?

  • A first good one is the Astro documentation, e.g. the installation guide where if a user select for example the pnpm package manager tab in the step 1, the step 2 command will be updated to show the pnpm command.
astro.mp4
firebase.mp4
  • I made a small component to quickly display npm related commands for multiple package managers in my Starlight documentation sites and it uses synced tabs like the Astro docs to sync the package manager selection between the tabs.
starlight-package-managers.mp4

API

I like the Astro docs current approach of basically passing a basic string to the <Tabs /> component when you want to sync the tabs. This is a very simple API and it is easy to understand what is going on imo.

Altho, I am personally not a huge fan of the sharedStore prop name currently used. It kinda feels like it is leaking some implementation details and not super obvious it's related to syncing the tabs. I would prefer something like sync or syncKey for example.

Any other ideas for the end user API?

Implementation

The Astro current implementation is using a Preact component with a hook to handle the sync which relies on Nano Stores to sync some state between the tabs. I would assume this is an approach we would want to avoid in Starlight as it would add some dependencies and one UI framework.

My component starlight-package-managers is using some MutationObserver to detect tab changes and update the selected tab accordingly. This choice is mostly due to the fact that I am doing that outside of Starlight and it is not a good choice for Starlight itself.

@delucis mentioned using a simple store primitive that each tabs instance subscribes to and updates from. This is a solid idea, we could expose a relatively small createStore() function returning a store with various methods like store.getState(), store.subscribe() and store.setState() for example. Later down the line we could even imagine some other components re-using this store primitive to sync some other state (altho I don't see an example right now).

Altho, after thinking about it for a bit, I wonder if right now, the simplest approach would not be to shove everything in the existing StarlightTabs component as I don't think we need that much to implement this feature. We could have a static property with a record of all the tabs instances initialized with a sync key keyed by these keys and a static method which would be called when a new tab is selected that would update the selected tab for all the tabs instances with the same sync key.

A quick diff could look like this (note that is a very rough draft, not properly written or thought, tested or anything):

diff --git a/packages/starlight/user-components/Tabs.astro b/packages/starlight/user-components/Tabs.astro
index 43af043..d4a1f38 100644
--- a/packages/starlight/user-components/Tabs.astro
+++ b/packages/starlight/user-components/Tabs.astro
@@ -1,11 +1,16 @@
 ---
 import { processPanels } from './rehype-tabs';

+interface Props {
+	syncKey?: string;
+}
+
+const { syncKey } = Astro.props;
 const panelHtml = await Astro.slots.render('default');
 const { html, panels } = processPanels(panelHtml);
 ---

-<starlight-tabs>
+<starlight-tabs data-sync-key={syncKey}>
 	{
 		panels && (
 			<div class="tablist-wrapper not-content">
@@ -70,8 +75,11 @@ const { html, panels } = processPanels(panelHtml);

 <script>
 	class StarlightTabs extends HTMLElement {
+		static #syncedTabs: Record<string, StarlightTabs[]> = {};
+
 		tabs: HTMLAnchorElement[];
 		panels: HTMLElement[];
+		#syncKey: string | undefined;

 		constructor() {
 			super();
@@ -79,6 +87,14 @@ const { html, panels } = processPanels(panelHtml);
 			this.tabs = [...tablist.querySelectorAll<HTMLAnchorElement>('[role="tab"]')];
 			this.panels = [...this.querySelectorAll<HTMLElement>('[role="tabpanel"]')];

+			this.#syncKey = this.dataset.syncKey;
+
+			if (this.#syncKey) {
+				const syncedTabs = StarlightTabs.#syncedTabs[this.#syncKey] ?? [];
+				syncedTabs.push(this);
+				StarlightTabs.#syncedTabs[this.#syncKey] = syncedTabs;
+			}
+
 			this.tabs.forEach((tab, i) => {
 				// Handle clicks for mouse users
 				tab.addEventListener('click', (e) => {
@@ -113,7 +129,19 @@ const { html, panels } = processPanels(panelHtml);
 			});
 		}

-		switchTab(newTab: HTMLAnchorElement | null | undefined, index: number) {
+		static #syncTabs(emitter: StarlightTabs, index: number) {
+			const syncKey = emitter.#syncKey;
+			if (!syncKey) return;
+			const syncedTabs = StarlightTabs.#syncedTabs[syncKey];
+			if (!syncedTabs) return;
+
+			for (const tab of syncedTabs) {
+				if (tab === emitter) continue;
+				tab.switchTab(tab.tabs[index], index, false);
+			}
+		}
+
+		switchTab(newTab: HTMLAnchorElement | null | undefined, index: number, sync = true) {
 			if (!newTab) return;

 			// Mark all tabs as unselected and hide all tab panels.
@@ -131,7 +159,10 @@ const { html, panels } = processPanels(panelHtml);
 			// Restore active tab to the default tab order.
 			newTab.removeAttribute('tabindex');
 			newTab.setAttribute('aria-selected', 'true');
-			newTab.focus();
+			if (sync) {
+				newTab.focus();
+				StarlightTabs.#syncTabs(this, index);
+			}
 		}
 	}

Any thoughts on this? Some other ideas?

Testing

If I am not mistaken, we are currently not testing any of the Starlight components. Do we want to add tests for this? If yes, what approach would we want to take? Spinning up something like Playwright to test the components in a browser could add a lot of confidence in this but may feels a bit too much just for this feature. Another approach as we are using vitest already could be switching the test environment from node to happy-dom for example for a new set of tests, extract the web component from the Astro component (like StarlightTOC) and figure out how to test it using happy-dom. I have never done that for web components but I would assume it is possible. Any thoughts on this?

Other

Did I miss anything? ^^

Participation

  • I am willing to submit a pull request for this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementSomething it would be good to improve

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions