Skip to content

Render language tab sets as a dropdown - #4198

Open
alexmarhaba wants to merge 8 commits into
mainfrom
language-tabs-dropdown
Open

alexmarhaba wants to merge 8 commits into
mainfrom
language-tabs-dropdown

Conversation

@alexmarhaba

@alexmarhaba alexmarhaba commented Sep 22, 2026 •

Copy link
Copy Markdown

Summary

Tab sets in the languages group carry one option per client language. On a page like the vector and full-text search quickstart that's nine tabs, which wrap onto two rows and push the code sample down. This renders those as a <select> instead.

Nothing else changes: api-examples (49 uses), docker (25), kibana-api (16), and the two-option os / operating-systems pickers all stay as tab strips — a dropdown would be a downgrade for those.

Current

CleanShot 2026-09-22 at 15 48 45

Proposal

CleanShot 2026-09-22 at 15 23 37

How it works

The <select> doesn't render anything itself. It ticks the same hidden radio input the tab label would have ticked, and the existing CSS shows the matching panel. So grouping, cross-page sync, sessionStorage, and the ?languages=python URL parameter keep working without being touched — a dropdown and a tab strip in the same group stay in sync with each other.

The tab strip stays in the markup; tabs.ts adds .tabs-dropdown-active at runtime to hide it. Without JavaScript you get working tabs rather than a dead control, and there's no flash of every language on load.

The gate is the group key, with a per-tab-set override:

::::{tab-set}
:dropdown: true    # or :dropdown: false to force a tab strip

Testing

  • 33 C# directive tests pass, including 4 new ones covering the gate (languages default, other groups, explicit opt-in, explicit opt-out).
  • New Assets/tabs.test.ts — 6 jsdom tests, including dropdown ↔ tab-strip sync in both directions and restore-from-session.
  • tsc, eslint, and prettier clean.
  • Built locally against the real quickstart page: all 13 languages tab sets became dropdowns, defaulted to Python, and stayed in sync; the operating-systems tabs on the same page stayed tabs.
  • I only ran the tab-scoped tests, not the full suite — CI is the first full run.

Open questions for reviewers

  1. Duplication with the API Explorer. It already has a language <select> (_ApiCodeSample.cshtml + api-docs.ts), which even shares the tab-id- storage namespace. I wrote parallel CSS rather than extracting a shared component, so the site would have two similar-but-not-identical pickers. Happy to unify first if that's preferred.
  2. Allowlist scope. Only languages today. api-examples is the obvious next candidate — deliberately left out.
  3. Design review. The styling is mine, not from a Figma or EUI spec, and would benefit from a designer's eye.

🤖 Generated with Claude Code

Tab sets in the `languages` group carry one option per client language,
which wraps onto two rows as a horizontal strip and pushes the code
sample down the page. Those now render as a <select> instead.

The gate is the group key: `languages` defaults to a dropdown, every
other group is unchanged. Authors can force either rendering on a
specific tab set with `:dropdown: true|false`.

The tab strip stays in the markup and tabs.ts adds .tabs-dropdown-active
at runtime to hide it, so pages without JavaScript keep working tabs
rather than a dead control, and there is no flash of every language on
load. The <select> only ticks the radio input the tab label would have
ticked, so grouping, sync, sessionStorage and the ?languages= URL
parameter all keep working untouched — a dropdown and a tab strip in the
same group stay in sync with each other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Docs preview (local build)

Handbook preview: https://docs-v3-preview.elastic.dev/elastic/docs-builder/pull/4198/

Sample changed pages:

@alexmarhaba
alexmarhaba marked this pull request as ready for review September 22, 2026 14:47
@alexmarhaba
alexmarhaba requested a review from a team as a code owner September 22, 2026 14:47
@alexmarhaba
alexmarhaba requested a review from akira28 September 22, 2026 14:47
eslint's no-unexpected-multiline flagged the indexed access split across
lines. Use a local instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: dropdown mode currently leaves hidden fallback radios keyboard-interactive, which can create invisible focus targets and state drift from the visible select control.


What is this? | From workflow: PR Review

Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.

if (wrapper) {
wrapper.hidden = false
}
tabs.classList.add('tabs-dropdown-active')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Dropdown mode leaves hidden radios interactive and unsynced

Once tabs-dropdown-active is applied, the tab labels are hidden but the fallback radio inputs remain keyboard-focusable (tabindex="0" in TabItemView.cshtml). A keyboard user can tab to those now-invisible radios and change selection via arrow keys; that flips visible content through CSS without going through the <select> change path, so dropdown value/group sync/session state can drift.

Please make radios non-interactive when dropdown mode is activated (for example in initDropdowns(), set each .tabs-input to tabIndex = -1 and/or disabled) so the <select> is the only active control while JS is on.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this was real. Fixed in cb26271.

initDropdowns() now sets tabIndex = -1 and aria-hidden="true" on the tab set's own .tabs-input elements when the dropdown takes over, so the <select> is the only control in reach while JS is on. I scoped it with :scope > .tabs-input so a nested tab set keeps its own inputs.

I went with tabIndex = -1 over disabled because the radios still have to be settable from JS and still drive the :checked CSS that shows the panels — this only removes them from the keyboard path, it does not change how the panels work.

Tab sets that stay as a strip are untouched, and there are two new tests covering both sides: radios go to -1 in dropdown mode, and stay at 0 when the strip is still visible.

The radios that drive the panels are opacity-0 rather than removed, and
TabItemView gives them tabindex="0". With the tab strip hidden, a
keyboard user could tab onto an invisible radio and arrow through it,
flipping the visible panel via CSS without the <select> ever seeing a
change event — leaving the control, the group sync and sessionStorage
pointing at a different tab than the one on screen.

Set tabIndex = -1 and aria-hidden on them when the dropdown takes over,
so the <select> is the only control in reach. Tab sets that stay as a
strip are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved.


What is this? | From workflow: PR Review

Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: the newly added tab directive tests currently fail to compile due to unresolved test-framework symbols and must be aligned with the project’s test setup before merge.


What is this? | From workflow: PR Review

Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.

}
}

public class LanguagesGroupRendersAsDropdownTests(ITestOutputHelper output) : DirectiveTest<TabSetBlock>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] New tests introduce unresolved xUnit symbols in a non-xUnit test project

This file currently compiles with the existing test stack, but the new cases add ITestOutputHelper and [Fact] without adding matching references. On this branch, dotnet test tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj -v minimal --nologo fails with CS0246 for both ITestOutputHelper and FactAttribute at these new lines, so CI cannot pass as-is.

Please rewrite these new test classes to use the same framework pattern already used elsewhere in this file (or add the required dependencies consistently), so the project builds again.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and fixed in b621711.

Root cause was the branch aging: #4182 migrated this project from xUnit v3 to TUnit after these tests were written. The four dropdown-gate classes still used the old pattern, so once main was merged in they stopped compiling.

They now match the classes around them — [InheritsTests] on the class, parameterless primary constructor, [Test] per case.

Verified locally with the same entry point CI uses, dotnet run --project build -c release -- unit-test: full suite green, and all four classes appear in the Elastic.Markdown.Tests report.

main migrated this project from xUnit v3 to TUnit (#4182) while this
branch was open. The four dropdown-gate classes still declared an
ITestOutputHelper parameter and [Fact], neither of which resolves any
more, so the merged branch failed to compile with CS0246.

Match the classes around them: [InheritsTests] on the class, a
parameterless primary constructor, and [Test] on each case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes.


What is this? | From workflow: PR Review

Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.

* fallback; tabs.ts adds .tabs-dropdown-active once the <select> is live.
*/
.tabs.tabs-dropdown-active {
.tabs-label {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Dropdown-active styles leak into nested tab sets

The new .tabs.tabs-dropdown-active rules apply to all descendant .tabs-label and .tabs-content nodes, not just this tab set's own items. If a tab panel contains a nested tab set, its labels are hidden and its content styling is rewritten unexpectedly.

Please scope these selectors to direct children (for example > .tabs-label and > .tabs-content) so nested tab sets keep their own behavior.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 127352b. Both rules now use > .tabs-label / > .tabs-content.

Worth spelling out why this one bites harder than it looks: a nested tab set has no <select> of its own, so hiding its labels left it with no control at all — not just restyled, unusable.

Verified on the compiled output rather than by eye, since jsdom does not apply CSS. _static/styles.css now emits:

.tabs.tabs-dropdown-active>.tabs-label
.tabs.tabs-dropdown-active>.tabs-content

Note the pre-existing .tabs-input:checked + .tabs-label + .tabs-content rule in the base block is still descendant-scoped. I left it alone as out of scope here — the sibling combinators limit the blast radius — but it is the same class of issue if anyone wants it tightened separately.

// The checked input is the source of truth — it may have been restored
// from sessionStorage or a URL param by ready().
const checked = tabs.querySelector<HTMLInputElement>(
'.tabs-input:checked'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Initial checked-radio lookup can read nested tab state

This lookup is unscoped:

const checked = tabs.querySelector<HTMLInputElement>('.tabs-input:checked')

In a dropdown tab set that contains nested tabs, if a non-first outer tab is restored/selected, traversal can encounter a checked nested radio before the outer set's checked radio and initialize the <select> to the wrong ID (or no matching option).

Please scope this to the current tab set's own radios (e.g. :scope > .tabs-input:checked).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, and reproduced before fixing. Fixed in 127352b with :scope > .tabs-input:checked.

I wrote the failing case first to be sure it was not theoretical: outer tab 2 restored as checked, outer tab 1 holding a nested tab set whose own first radio is checked. The nested radio comes first in document order, so select.value was being set to an id matching none of its options. Test failed on the old code, passes now.

This also makes the file consistent — the tabIndex = -1 fix in cb26271 was already using :scope >, and this lookup was the one place still walking the whole subtree.

@itsalexcm

itsalexcm commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

The overlap is real, and this PR can land without extracting a shared component.

The API Explorer language control (_ApiSelect in _ApiCodeSample.cshtml, group api-language) and this <select> do the same job on different hosts. One switches code-sample panels. The other ticks the existing tab radios. Keeping tab-id-languages and tab-id-api-language separate is right: choosing Python in a guide should leave the API example alone.

Unifying the markup means replacing either the native <select> or the API menu. That is a larger change than this page. Shipping the languages dropdown as it is is the right scope, and api-examples can stay out.

What should stay aligned is the look. API pages already have a language control, and a second style beside it will read as two products. I'll look at both and follow up on what this dropdown should match.

Both the CSS and the initial select lookup reached into nested tab sets:

- `.tabs.tabs-dropdown-active { .tabs-label { ... } }` compiles to a
  descendant selector, so a nested tab set inside a panel had its labels
  hidden and its content padding rewritten. With no <select> of its own,
  that left it with no control at all.
- `tabs.querySelector('.tabs-input:checked')` walks the whole subtree. If
  a non-first outer tab is the restored one, the nested set's checked
  radio comes first in document order, so the <select> initialised from
  an id matching none of its options.

Use direct children in both places, matching the scoping already used
for the tabindex fix. Two tests cover the lookup and the nested set
keeping its own radios; the compiled CSS now emits `>` combinators.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes.


What is this? | From workflow: PR Review

Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.

function syncSelectToLabel(label: HTMLElement) {
const select = label
.closest('.tabs')
?.querySelector<HTMLSelectElement>('.tabs-select')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Descendant .tabs-select lookup can desync nested dropdowns

syncSelectToLabel() currently does label.closest('.tabs')?.querySelector('.tabs-select'), which searches the entire descendant tree. For a tab strip that has no own dropdown but contains a nested dropdown tab-set inside one panel, this picks the nested <select> and assigns it the outer label's for id.

That id does not belong to the nested options, so the nested dropdown can be set to an invalid/empty value while its radios remain on a different selection. Please scope this to the current tab-set's own toolbar (for example :scope > .tabs-select-wrapper > .tabs-select) so only the owning tab-set is updated.

tabs.ts: main moved tab init to delegated change/beforematch listeners
and hid inactive panels with hidden="until-found". The dropdown code is
kept on top of that. The select handler now syncs the set's panels,
because setting a radio from script fires no change event, and a
find-in-page match also moves the select to the revealed tab.

tabs.test.ts: both sides added the file. Both suites are kept, with the
dropdown helper renamed to dropdownTabSet to avoid a name clash.

TabSetViewModel: main added a design-system tab-set path that builds the
view model without the new dropdown members, so they are no longer
required and default to the plain tab strip. That path renders no
dropdown yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +61 to +63
> .tabs-content {
@apply border-t-0 pt-4;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: P3 (Low)

Keep inactive panels at zero padding. This rule has the same specificity as .tabs .tabs-content[hidden='until-found'] and appears later, so pt-4 overrides its padding: 0. In browsers supporting hidden="until-found", every inactive language panel therefore retains a padding box, adding blank space around the selected sample that grows with the number of languages. Apply the padding only to the visible panel or restore zero padding for hidden panels afterward.

Generated by Libra. React with 👍 or 👎 to give feedback on this comment.

Comment on lines +197 to +198
[Test]
public void DefaultsToDropdownForLanguages() => Block!.RenderAsDropdown().Should().BeTrue();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: P3 (Low)

Add assertions against the rendered dropdown HTML, not just RenderAsDropdown(). All four new C# tests check only the gate, while the TypeScript tests supply handwritten markup. Nothing verifies that the actual renderer emits the dropdown, that option values reference the generated radio IDs, or that opting out removes the dropdown, so a broken server-to-client contract would still pass these tests.

Generated by Libra. React with 👍 or 👎 to give feedback on this comment.

This branch was successfully deployed

2 active deployments
docs-preview — 76301212 Deployed Nov 4, 2026 by github-actions[bot]
assembler-preview — 76301212 Deployed Nov 4, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants