Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: '@file'
created: 2026-10-14T12:13:47Z
norm: '1'
sig: 021fb596db81e6d0
body_hash: 58cb3a1422668a90
raw_hash: 8a0cfa65a04cebac
body_hash: 97a8cd64316f6c09
raw_hash: 5adef943d9bdc624
vouched: 2026-10-14T12:24:22Z
lines: 1-92
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4X5XKE2EH5S9CVKJ134JX8E
anchor: function Sidebar
created: 2026-10-14T12:24:14Z
norm: '1'
sig: ffd0cb76766d2b8b
body_hash: a07b63f60dcd52b8
raw_hash: 944e37427b30b00a
lines: 383-428
---

a closing sidebar finds its toggle via [aria-controls=<sidebar id>]; consumers must give the sidebar an id and its toggles aria-controls, or focus falls to the body
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4X5XKE6X272FC43XBDBS67B
anchor: function Layout
created: 2026-10-14T12:24:14Z
norm: '1'
sig: 57557e00fb6f195d
body_hash: b26cc056d8f5926c
raw_hash: 9d8652d99228510c
lines: 224-373
---

the compact overlay effect must stay a layout effect and leave open sidebars' inert alone, or main stays inert when focus moves to the toggle and closed sidebars lose inert on cleanup
1 change: 1 addition & 0 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ export function App() {
{page !== "dashboard" && current && (
<Sidebar
side="left"
id="sidebar"
className="-my-2 h-[calc(100%+1rem)]"
innerClassName="h-full min-h-0"
>
Expand Down
2 changes: 0 additions & 2 deletions apps/app/src/channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,6 @@ export function ChannelDetails({ channel }: { channel: Listing }) {
<Sidebar
side="right"
id="channel-details"
aria-hidden={!right.open}
inert={!right.open}
className="-my-2 h-[calc(100%+1rem)]"
innerClassName="h-full min-h-0"
>
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/github.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ export function Github({ kind, project, connected, onCreate }: {
variant="ghost"
size="icon-sm"
aria-label={`Toggle ${NAMES[kind].toLowerCase()} sidebar`}
aria-controls="sidebar"
aria-expanded={left.open}
onClick={() => left.setOpen((value) => !value)}
>
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/layout/bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function Bar({ children }: { children?: ReactNode }) {
variant="ghost"
size="icon-sm"
aria-label="Toggle channels sidebar"
aria-controls="sidebar"
aria-expanded={left.open}
className={lead}
onClick={() => left.setOpen((open) => !open)}
Expand Down
34 changes: 27 additions & 7 deletions packages/ui/src/components/layout/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type Ref,
type SetStateAction,
use,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -285,7 +285,9 @@ function Layout({
}, [width, minLeftWidth, maxLeftWidth, defaultLeftWidth, leftWidth, onLeftWidthChange]);

useSuppressMotion(shell);
useEffect(() => {
// A layout effect so the cleanup lifts inert from the main panel before a closing
// sidebar's layout effect hands focus to its toggle there.
useLayoutEffect(() => {
if (!leftCompact && !rightCompact) return;

let el = shell.current;
Expand All @@ -294,7 +296,6 @@ function Layout({
let items = Array.from(el.children).filter((item): item is HTMLElement =>
item instanceof HTMLElement
);
let prev = items.map(item => [item, item.inert] as const);
let active = new Set(
items.filter((item) => {
if (leftCompact && item.classList.contains("layout:left")) return true;
Expand All @@ -305,8 +306,10 @@ function Layout({

if (!active.size) return;

items.forEach(item => {
item.inert = !active.has(item);
// An open sidebar owns its inert prop; restoring a stale value would override it on close.
let prev = items.filter(item => !active.has(item)).map(item => [item, item.inert] as const);
prev.forEach(([item]) => {
item.inert = true;
});

return () => {
Expand Down Expand Up @@ -388,6 +391,14 @@ function Sidebar({ children, side, className, innerClassName, ref, ...props }: S

useClickOutside(node, compact, () => item.setOpen(false), SLOT);

// Runs before the browser drops focus from the newly inert subtree, so it can hand focus to
// the visible control that toggles this sidebar instead of the document body.
useLayoutEffect(() => {
let el = node.current;
if (item.open || !el?.contains(document.activeElement)) return;
document.querySelector<HTMLElement>(`[aria-controls="${el.id}"]`)?.focus();
}, [item.open]);

return (
<div
ref={(el) => {
Expand All @@ -399,6 +410,10 @@ function Sidebar({ children, side, className, innerClassName, ref, ...props }: S
"electrobun-webkit-app-region-no-drag",
className,
)}
// The desktop left track collapses to zero width but stays visible, so hiding the closed
// sidebar from focus and assistive technology can't rely on CSS visibility.
aria-hidden={!item.open}
inert={!item.open}
{...props}
>
{side === "left" && <LeftDivider />}
Expand Down Expand Up @@ -441,7 +456,7 @@ function LeftResize() {

// The sidebar is a fixed grid column only on desktop; below `sm` it's an
// overlay of fixed size, so dragging doesn't apply.
if (!width || !sm || !left.open) return null;
if (!width || !sm) return null;

function down(event: PointerEvent<HTMLButtonElement>) {
if (!width || event.button !== 0) return;
Expand Down Expand Up @@ -477,7 +492,12 @@ function LeftResize() {
onPointerUp={up}
onPointerCancel={up}
onDoubleClick={() => width.reset()}
className="group absolute inset-y-0 right-0 z-10 hidden w-3 cursor-col-resize touch-none select-none bg-transparent sm:block"
// Hidden rather than unmounted when closed, so a focused grip is still inside the sidebar
// when it hands focus to its toggle.
className={cn(
"group absolute inset-y-0 right-0 z-10 hidden w-3 cursor-col-resize touch-none select-none bg-transparent",
left.open && "sm:block",
)}
>
<span className="pointer-events-none absolute inset-y-0 right-0 w-px bg-primary opacity-0 transition-opacity duration-150 group-hover:opacity-100" />
</button>
Expand Down