Skip to content

Add dropdown appearance to applies-switch for versioned code examples - #3739

Open
florent-leborgne wants to merge 1 commit into
mainfrom
flb/applies-switch-dropdown
Open

Add dropdown appearance to applies-switch for versioned code examples#3739
florent-leborgne wants to merge 1 commit into
mainfrom
flb/applies-switch-dropdown

Conversation

@florent-leborgne

@florent-leborgne florent-leborgne commented Jul 28, 2026

Copy link
Copy Markdown
Member

Docs-eng: treat this as a suggestion/idea

Why

docs-content needs to document API payloads that changed across versions — for example the dashboards API, with a 9.0 technical preview shape and breaking changes in 9.1/serverless. Duplicating a snippet's surrounding prose per version doesn't scale, and the tabs appearance spends a full-width tab bar on every snippet, which gets noisy on pages with many versioned examples.

What

{applies-switch} accepts :appearance: dropdown: a compact selector chip attached to the upper-right corner of each item's code block, opening a floating panel with the alternatives.

  • Items take the full applies_to syntax; each item carries its own code block and callout list, so callouts always match the selected version.
  • The selector shows compact labels (9.1+, ECH 8.0+, 9.0 (preview)) derived from the same applicability pipeline as badges; the full definition shows on hover.
  • The appearance only changes presentation: switches sharing applies_to definitions stay in sync page-wide regardless of appearance (dropdown ↔ tabs), and selection persists per session.
  • Guard rails: every item must start with a code block, otherwise the switch warns and falls back to tabs (same pattern as unknown :appearance: values). Future content types can be supported by adapting the chip's anchoring, without new syntax.
  • :selected: now sets the default item (previously parsed but ignored); multiple :selected: warn. Also fixes hidden radios intercepting pointer events on the selector, and extends selection sync to keyboard and panel-row selections.
  • Hardens llms.txt generation against code blocks with no content lines, which crashed the LLM exporter with a NullReferenceException.

The default tabs appearance is untouched; docs-content has no existing :selected: usage, so behavior of existing content is unchanged.

Docs: new sections with live demos in syntax/applies-switch.md. Tests: 18 new (appearance parsing, guard fallback, default selection, panel markup, label format, LLM export).

Screenshots

Closed chip and open panel captures to be attached below.

🤖 Generated with Claude Code

Documentation increasingly shows API payloads that differ across
versions and deployment types. The tabs appearance spends a full-width
tab bar per snippet and scales poorly on pages with many versioned
examples.

:appearance: dropdown renders an applies-switch as a compact selector
chip attached to the top-right corner of each item's code block, with
a floating panel listing the alternatives. Items accept the full
applies_to syntax and each carries its own code block and callout
list, so callouts always match the selected version. Selections render
as compact text labels (9.1+, ECH 8.0+, 9.0 (preview)) derived from
the same applicability pipeline as badges, sync across all switches on
the page regardless of appearance, and persist per session.

Guard rails: every item must start with a code block, otherwise the
switch warns and falls back to tabs; unknown :appearance: values do
the same. :selected: now marks the default item (previously parsed but
ignored); multiple :selected: items warn. Hidden radios no longer
intercept pointer events in the selector, and selection sync now also
covers keyboard and panel-row selections via change events.

Also hardens llms.txt generation against code blocks with no content
lines, which crashed the LLM exporter with a NullReferenceException.

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

@reakaleek reakaleek left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for this. The problem statement is clear, and the guardrails (code-block-first fallback, unknown appearance → tabs, sync across appearances, LLM empty-fence fix) make the feature feel safe to try.

Most of the inline notes are about aligning the dropdown skin with existing markdown chrome, plus a couple of accessibility/validity checks on the custom menu. None of that questions the overall approach.

Happy to chat through the left-vs-right chip placement if useful. Left would match tabs and LTR scanning habits, while keeping the chevron on the right of the label text.

Comment on lines +73 to +74
border-top-right-radius: 0.5rem;
box-shadow: 0 -2px 6px rgb(0 0 0 / 0.05);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These 0.5rem corners (and the panel's rounded-lg / row rounded-md below) read softer than the surrounding markdown chrome. Tabs and applies-switch tabs are square, code blocks use rounded-t-sm, and {dropdown} uses rounded-sm + shadow-xs.

Matching that smaller radius family would help this sit next to existing snippets without looking like a different component set.

Comment on lines +134 to +147
transform: translateY(-4px);
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}

.applies-switch-panel-row {
@apply text-ink-light flex cursor-pointer items-center rounded-md px-3 text-sm whitespace-nowrap;
height: 1.875rem;
transition: background-color 0.12s ease;

&:hover {
@apply bg-grey-10 text-black;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Closed state only uses opacity: 0 and pointer-events: none, so the panel can still sit in the accessibility tree. Screen readers may announce options while the menu looks closed.

visibility: hidden, the hidden attribute, or inert when not .open would keep the closed state truly closed for AT as well.


.applies-switch-select {
@apply border-grey-30 absolute top-0 right-0 z-10 flex w-max cursor-pointer flex-col items-stretch overflow-hidden border-1 border-b-0 bg-white;
border-top-left-radius: 0.5rem;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The chip sits on the trailing edge (right-0), while the tabs appearance and {tabs} both start their selectors on the leading edge. In LTR layouts that left edge is also where readers scan first (NN/g on right-justified menus).

Would anchoring with left-0 (and flipping rounded-tr-nonerounded-tl-none on the code corner) keep this aligned with the rest of the switch family? Chevron inside the chip can stay on the right of the label.

/* Floating options panel, right-aligned 4px below the chip. */
.applies-switch-panel {
@apply absolute right-0 z-20 w-max rounded-lg bg-white p-1;
top: calc(2.25rem + 1px + 4px);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The markdown {dropdown} rotates its chevron with no transition (dropdown.css only sets transform on [open]). The animated rotate here feels a bit busier next to that pattern.

Dropping the chevron transition (instant open/close) would match the existing dropdown. If the panel fade stays, a prefers-reduced-motion off-switch would be good.

Comment on lines +158 to +160
the top padding matches the chip's outer height exactly
(2.25rem label + 1px top border) so the block's top edge runs
at the same line beside and under the chip. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hiding the current row keeps the panel short, though it also removes the selected item from the list. NN/g generally prefers keeping options in place (and VersionDropdown shows the current version with a check).

With only two items the panel becomes a single alternative, and with one item it can open empty. Showing all rows and marking the current one might be clearer.

<div class="applies-switch@(Model.IsDropdown ? " applies-switch--dropdown" : "")">
@if (Model.IsDropdown)
{
<div class="applies-switch-select" title="Select another version">

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Open/close is driven by a CSS class on the wrapper, and the accessible name is only a title on this non-focusable div. Assistive tech won't get a clear disclosure control.

Wiring aria-expanded / aria-controls on the trigger (and toggling them from the click handler) would make the open state explicit. Escape close is a nice touch already.

var id = $"applies-switch-item-{Model.AppliesSwitchIndex}-{Model.Index}";
}

<input class="applies-switch-input" checked="@(Model.Checked ? "checked" : "")" id="@id" name="applies-switch-set-@Model.AppliesSwitchIndex" type="radio" tabindex="0" data-index="@Model.Index">

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When Checked is false this can render checked="". For boolean HTML attributes, presence alone can mark the radio checked, so :selected: on a later item might leave more than one input looking selected in the DOM.

Worth confirming Razor omits the attribute when false (e.g. pass null, or a conditional attribute). The tests assert the checked="checked" string, which wouldn't catch an empty checked="".

Comment on lines +85 to 102
function syncSelection(label: HTMLElement) {
const data = create_key(label)
if (!data) return
const [group, id, key] = data
for (const label of as_id_to_elements[key]) {
if (label === this) {
for (const other of as_id_to_elements[key] ?? []) {
if (other === label) {
continue
}
if (label.previousElementSibling instanceof HTMLInputElement) {
label.previousElementSibling.checked = true
if (other.previousElementSibling instanceof HTMLInputElement) {
other.previousElementSibling.checked = true
// Setting .checked programmatically fires no change event, so
// synced dropdowns need their content panes updated explicitly
const dropdown = other.closest('.applies-switch--dropdown')
if (dropdown) updateDropdownContents(dropdown)
}
}
window.sessionStorage.setItem(storageKeyPrefix + group, id)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice catch that programmatic .checked skips the change event. Updating dropdown panes from here keeps tabs ↔ dropdown sync working without fighting the CSS sibling selector used by the tabs appearance.

@Mpdreamz

Copy link
Copy Markdown
Member

Nice,

Two concerns:

  1. Make it its own directive
::::{code-switch}

:::{applies-item} stack: ga 9.0+
```code
```
:::

:::{applies-item} stack: experimental 9.1+
```code
```
:::

:::{applies-item} serverless: ga
```code
```
:::

::::

Instead of having an :option: dictate what elements are allowed.

  1. How will this interact with multiple languages?
image

We could extend this code-switch to also have a per language toggle:

::::{code-switch}

:::{language-item} 
```js
```
:::
:::{language-item} 
```pyhon
```
:::
::::

And even combine the two so you can have per version language toggles.

:::::{code-switch}

::::{applies-item} stack: ga 9.0+
:::{language-item} 
```js
```
:::
:::{language-item} 
```python
```
:::
::::

//more
:::::

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants