Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8ff14cb
Refactor mobile menu to use Popover API
delucis Aug 13, 2026
ce9b01a
Update browser versions link in CONTRIBUTING.md
delucis Aug 13, 2026
1408ee6
Fix browserslist range
delucis Aug 13, 2026
0586e9f
Add changesets
delucis Aug 13, 2026
d36e058
Use `beforetoggle` event
delucis Aug 13, 2026
a343bb1
Further refactor so that body locking is not dependent on JS either
delucis Aug 13, 2026
39a8b15
Merge branch 'main' into chris/sidebar-refactor
louisescher Aug 13, 2026
77300e8
Fix browser list
delucis Aug 14, 2026
7358276
Remove polyfill recommendation
delucis Aug 14, 2026
eaf60a1
Inherit text color in `popover`
delucis Aug 14, 2026
a4535bd
Update Edge version in contributing guide browser list
delucis Aug 14, 2026
050b7ca
Refactor menu pane to be a custom element
delucis Aug 14, 2026
cbf64ca
Close the menu when the viewport resizes instead of only managing the…
delucis Aug 14, 2026
837b654
Mention overrides in changeset
delucis Aug 14, 2026
a1986b7
Use `toggle` event
delucis Aug 14, 2026
5c73fb3
Move menu toggle script to PageFrame
delucis Aug 14, 2026
62964d3
Add class name to menu button to help migration
delucis Aug 14, 2026
5ae97d6
Fix E2E test to make it a bit more robust
delucis Aug 14, 2026
1e62e46
Fix aria-expanded example in changeset
delucis Aug 14, 2026
7e23a75
Update changeset to mention `data-mobile-menu-expanded` change
delucis Aug 14, 2026
2529960
Merge branch 'main' into chris/sidebar-refactor
delucis Sep 1, 2026
c3a8f08
Merge branch 'main' into chris/sidebar-refactor
delucis Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/clean-flies-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@astrojs/starlight': minor
---

Simplifies markup for Starlight’s mobile menu toggle

**⚠️ Potentially breaking change:** If you use a theme plugin, custom styles, or component overrides targeting the `MobileMenuToggle` button or `PageFrame` components, you may need to adjust these for the new markup. The button is no longer wrapped in a `<starlight-menu-button>` custom element and no longer uses the `aria-expanded` attribute. Instead, you can use the `.sl-menu-button` class name to target the button and the `:popover-open` pseudo-class to style the menu open state specifically.

In the following example, custom styles for the menu button are updated for the new approach:

```diff
- starlight-menu-button button {
+ .sl-menu-button {
color: var(--sl-color-text);
}

- starlight-menu-button[aria-expanded='true'] button {
+ .sl-menu-button:has(~ :popover-open) {
color: var(--sl-color-text-accent-high);
}
```

See [`MobileMenuToggle.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/MobileMenuToggle.astro) and [`PageFrame.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/PageFrame.astro) on GitHub for the full source code of the updated components.
18 changes: 18 additions & 0 deletions .changeset/long-jokes-post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@astrojs/starlight': minor
---

Refactors Starlight’s mobile menu toggle to work when JavaScript fails or is disabled

⚠️ **BREAKING CHANGE:** This release drops official support for Chromium-based browsers prior to version 116 (released August 2023), Safari-based browsers prior to version 17.0 (released September 2023), and Firefox prior to version 125 (released April 2024). You can find a list of currently supported browsers and their versions using this [browserslist query](https://browsersl.ist/#q=%3E+0.5%25%2C+not+dead%2C+Chrome+%3E%3D+116%2C+Edge+%3E%3D+116%2C+Firefox+%3E%3D+125%2C+Safari+%3E%3D+17.0%2C+iOS+%3E%3D+17.0%2C+not+op_mini+all).

This change also removes the `data-mobile-menu-expanded` attribute, which was previously added to `<body>` while the mobile menu is open. If you have custom code that was depending on this attribute, you will need to update it to use a new selector to check if the mobile menu is open.

In the following example, a custom background colour for the site header while the menu is open is updated for the new approach:

```diff
- [data-mobile-menu-expanded] header {
+ body:has(sl-sidebar-pane:popover-open) header {
background-color: var(--sl-color-bg);
}
```
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ To add a language, you will need its BCP-47 tag and a label. See [“Adding a ne

- Components that require client-side JavaScript or CSS should use JavaScript/CSS features that are well-supported by browsers.

You can find a list of supported browsers and their versions using this [browserslist query](https://browsersl.ist/#q=%3E+0.5%25%2C+not+dead%2C+Chrome+%3E%3D+111%2C+Edge+%3E%3D+111%2C+Firefox+%3E%3D+121%2C+Safari+%3E%3D+16.4%2C+iOS+%3E%3D+16.4%2C+not+op_mini+all). To check whether or not a feature is supported, you can visit the [Can I use](https://caniuse.com) website and search for the feature.
You can find a list of supported browsers and their versions using this [browserslist query](https://browsersl.ist/#q=%3E+0.5%25%2C+not+dead%2C+Chrome+%3E%3D+116%2C+Edge+%3E%3D+116%2C+Firefox+%3E%3D+125%2C+Safari+%3E%3D+17.0%2C+iOS+%3E%3D+17.0%2C+not+op_mini+all). To check whether or not a feature is supported, you can visit the [Can I use](https://caniuse.com) website and search for the feature.

[discord]: https://astro.build/chat
[issues]: https://github.com/withastro/starlight/issues
Expand Down
8 changes: 8 additions & 0 deletions packages/starlight/__e2e__/basics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,11 +779,13 @@ test.describe('mobile menu focus trap', () => {
await starlight.goto('/headings');

const currentFocus = page.locator('*:focus');
const mainFrame = page.locator('.main-frame');

// Open the mobile menu.
const mobileMenuButton = page.getByRole('button', { name: 'Menu' });
await mobileMenuButton.click();
await expect(currentFocus).toHaveCount(1);
await expect(mainFrame).toHaveAttribute('inert', '');

// Focus the theme selector, which is the last focusable element in the mobile menu.
const themeSelector = page.getByRole('navigation', { name: 'Main' }).getByLabel('Select theme');
Expand All @@ -801,6 +803,7 @@ test.describe('mobile menu focus trap', () => {
await mobileMenuButton.click();

// The focus trap should be released and tabbing will focus the mobile table of contents button.
await expect(mainFrame).not.toHaveAttribute('inert', '');
await page.keyboard.press('Tab');
await expect(currentFocus).toHaveText(/On this page/);
});
Expand All @@ -811,6 +814,7 @@ test.describe('mobile menu focus trap', () => {
await starlight.goto('/anchor-heading');

const currentFocus = page.locator('*:focus');
const mainFrame = page.locator('.main-frame');
const anchorLinkAccessibleName = 'Section titled “An anchor heading”';
const anchorHeadingLink = page.getByRole('link', { name: anchorLinkAccessibleName });

Expand All @@ -822,6 +826,7 @@ test.describe('mobile menu focus trap', () => {
const mobileMenuButton = page.getByRole('button', { name: 'Menu' });
await mobileMenuButton.click();
await expect(currentFocus).toHaveAccessibleName('Menu');
await expect(mainFrame).toHaveAttribute('inert', '');

// Try to focus the anchor heading which should be prevented by the focus trap,
// keeping focus where it is.
Expand All @@ -832,11 +837,14 @@ test.describe('mobile menu focus trap', () => {
await page.setViewportSize({ width: 1280, height: 720 });

// The anchor heading link should be focusable again.
await expect(mainFrame).not.toHaveAttribute('inert', '');
await anchorHeadingLink.focus();
await expect(currentFocus).toHaveText(anchorLinkAccessibleName);

// Resizing back to a smaller viewport should not re-enable the focus trap.
await page.setViewportSize({ width: 375, height: 667 });
await expect(mainFrame).not.toHaveAttribute('inert', '');
await expect(page.locator('.sidebar-pane:popover-open')).toHaveCount(0);
await expect(currentFocus).toHaveText(anchorLinkAccessibleName);
});
});
Expand Down
71 changes: 11 additions & 60 deletions packages/starlight/src/components/MobileMenuToggle.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,60 +2,11 @@
import Icon from '../user-components/Icon.astro';
---

<starlight-menu-button class="print:hidden">
<button
aria-expanded="false"
aria-label={Astro.locals.t('menuButton.accessibleLabel')}
aria-controls="starlight__sidebar"
class="sl-flex md:sl-hidden"
>
<Icon name="bars" class="open-menu" />
<Icon name="close" class="close-menu" />
</button>
</starlight-menu-button>

<script>
class StarlightMenuButton extends HTMLElement {
btn = this.querySelector('button')!;

constructor() {
super();
// Toggle `aria-expanded` state when a user clicks the button.
this.btn.addEventListener('click', () => this.toggleExpanded());

// Close the menu when a user presses the escape key.
const parentNav = this.closest('nav');
if (parentNav) {
parentNav.addEventListener('keyup', (e) => this.closeOnEscape(e));
}

// Reset menu state when the viewport toggles between mobile and desktop sizes.
matchMedia('(min-width: 50em)').addEventListener('change', () => this.setExpanded(false));
}

setExpanded(expanded: boolean) {
this.setAttribute('aria-expanded', String(expanded));
document.body.toggleAttribute('data-mobile-menu-expanded', expanded);
// Trap keyboard focus inside the header and menu when the menu is expanded.
document.querySelectorAll<HTMLElement>('.main-frame, .sl-skip-link').forEach((el) => {
el.toggleAttribute('inert', expanded);
});
}

toggleExpanded() {
this.setExpanded(this.getAttribute('aria-expanded') !== 'true');
}

closeOnEscape(e: KeyboardEvent) {
if (e.code === 'Escape') {
this.setExpanded(false);
this.btn.focus();
}
}
}

customElements.define('starlight-menu-button', StarlightMenuButton);
</script>
<button popovertarget="starlight__sidebar" class="sl-menu-button sl-flex md:sl-hidden print:hidden">
<Icon name="bars" class="open-menu" />
<Icon name="close" class="close-menu" />
<span class="sr-only">{Astro.locals.t('menuButton.accessibleLabel')}</span>
</button>

<style>
@layer starlight.core {
Expand All @@ -75,16 +26,16 @@ import Icon from '../user-components/Icon.astro';
cursor: pointer;
}

[aria-expanded='true'] button {
button:has(~ :popover-open) {
background-color: var(--sl-color-gray-2);
box-shadow: none;
}

[aria-expanded='true'] button .open-menu {
button:has(~ :popover-open) .open-menu {
display: none;
}

:not([aria-expanded='true']) button .close-menu {
button:not(:has(~ :popover-open)) .close-menu {
display: none;
}

Expand All @@ -93,20 +44,20 @@ import Icon from '../user-components/Icon.astro';
color: var(--sl-color-white);
}

:global([data-theme='light']) [aria-expanded='true'] button {
:global([data-theme='light']) button:has(~ :popover-open) {
background-color: var(--sl-color-gray-5);
}
}
</style>

<style is:global>
@layer starlight.core {
[data-mobile-menu-expanded] {
body:has(#starlight__sidebar:popover-open) {
overflow: hidden;
}

@media (min-width: 50rem) {
[data-mobile-menu-expanded] {
body:has(#starlight__sidebar:popover-open) {
overflow: auto;
}
}
Expand Down
40 changes: 32 additions & 8 deletions packages/starlight/src/components/PageFrame.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,33 @@ const { hasSidebar } = Astro.locals.starlightRoute;
hasSidebar && (
<nav class="sidebar print:hidden" aria-label={Astro.locals.t('sidebarNav.accessibleLabel')}>
<MobileMenuToggle />
<div id="starlight__sidebar" class="sidebar-pane">
<script>
// The mobile menu uses the popover API to open and close without JavaScript. We enhance this
// behaviour with focus trapping while the menu is open.
class StarlightSidebarPane extends HTMLElement {
constructor() {
super();

// Close the menu when the viewport is resized between mobile and desktop sizes.
matchMedia('(min-width: 50em)').addEventListener('change', () => this.hidePopover());

// Update the menu state when the sidebar popover is toggled.
this.addEventListener('toggle', ({ newState }) => this.trapFocus(newState === 'open'));
}
/** Trap keyboard focus inside the header and menu when the menu is expanded. */
trapFocus(shouldTrap: boolean) {
document.querySelectorAll<HTMLElement>('.main-frame, .sl-skip-link').forEach((el) => {
el.toggleAttribute('inert', shouldTrap);
});
}
}
customElements.define('sl-sidebar-pane', StarlightSidebarPane);
</script>
<sl-sidebar-pane popover="" id="starlight__sidebar" class="sidebar-pane">
<div class="sidebar-content sl-flex">
<slot name="sidebar" />
</div>
</div>
</sl-sidebar-pane>
</nav>
)
}
Expand Down Expand Up @@ -48,7 +70,12 @@ const { hasSidebar } = Astro.locals.starlightRoute;
}

.sidebar-pane {
visibility: var(--sl-sidebar-visibility, hidden);
/* By default, user agents apply some opinionated styles to popovers, which we don’t want. */
border: unset;
padding: unset;
height: unset;
Comment thread
delucis marked this conversation as resolved.
color: unset;

position: fixed;
z-index: var(--sl-z-index-menu);
inset-block: var(--sl-nav-height) 0;
Expand All @@ -59,10 +86,6 @@ const { hasSidebar } = Astro.locals.starlightRoute;
scrollbar-gutter: stable;
}

:global([aria-expanded='true']) ~ .sidebar-pane {
--sl-sidebar-visibility: visible;
}

.sidebar-content {
height: 100%;
min-height: max-content;
Expand All @@ -88,7 +111,8 @@ const { hasSidebar } = Astro.locals.starlightRoute;
padding-inline-end: var(--sl-nav-pad-x);
}
.sidebar-pane {
--sl-sidebar-visibility: visible;
/* Make the sidebar visible on larger screens instead of deferring to popover state. */
display: block;
width: var(--sl-sidebar-width);
background-color: var(--sl-color-bg-sidebar);
border-inline-end: 1px solid var(--sl-color-hairline-shade);
Expand Down
Loading