diff --git a/.lane/memory/package.json/01M4X5AF9TCS4B752Z16B7W75M-oxlint-applies-the-shared-gi.md b/.lane/memory/package.json/01M4X5AF9TCS4B752Z16B7W75M-oxlint-applies-the-shared-gi.md index 5d2986d1..77b24cae 100644 --- a/.lane/memory/package.json/01M4X5AF9TCS4B752Z16B7W75M-oxlint-applies-the-shared-gi.md +++ b/.lane/memory/package.json/01M4X5AF9TCS4B752Z16B7W75M-oxlint-applies-the-shared-gi.md @@ -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 --- diff --git a/.lane/memory/packages/ui/src/components/layout/layout.tsx/01M4X5XKE2EH5S9CVKJ134JX8E-a-closing-sidebar-finds-its.md b/.lane/memory/packages/ui/src/components/layout/layout.tsx/01M4X5XKE2EH5S9CVKJ134JX8E-a-closing-sidebar-finds-its.md new file mode 100644 index 00000000..0f37ca0b --- /dev/null +++ b/.lane/memory/packages/ui/src/components/layout/layout.tsx/01M4X5XKE2EH5S9CVKJ134JX8E-a-closing-sidebar-finds-its.md @@ -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=]; consumers must give the sidebar an id and its toggles aria-controls, or focus falls to the body diff --git a/.lane/memory/packages/ui/src/components/layout/layout.tsx/01M4X5XKE6X272FC43XBDBS67B-the-compact-overlay-effect-m.md b/.lane/memory/packages/ui/src/components/layout/layout.tsx/01M4X5XKE6X272FC43XBDBS67B-the-compact-overlay-effect-m.md new file mode 100644 index 00000000..b23ba411 --- /dev/null +++ b/.lane/memory/packages/ui/src/components/layout/layout.tsx/01M4X5XKE6X272FC43XBDBS67B-the-compact-overlay-effect-m.md @@ -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 diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index 493b9239..a8aca23d 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -506,6 +506,7 @@ export function App() { {page !== "dashboard" && current && ( diff --git a/apps/app/src/channel.tsx b/apps/app/src/channel.tsx index 7a609fed..00cb2ec7 100644 --- a/apps/app/src/channel.tsx +++ b/apps/app/src/channel.tsx @@ -167,8 +167,6 @@ export function ChannelDetails({ channel }: { channel: Listing }) { diff --git a/apps/app/src/github.tsx b/apps/app/src/github.tsx index 610ae1b6..8f482d56 100644 --- a/apps/app/src/github.tsx +++ b/apps/app/src/github.tsx @@ -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)} > diff --git a/apps/app/src/layout/bar.tsx b/apps/app/src/layout/bar.tsx index dcaf73e5..6c3d8b61 100644 --- a/apps/app/src/layout/bar.tsx +++ b/apps/app/src/layout/bar.tsx @@ -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)} diff --git a/packages/ui/src/components/layout/layout.tsx b/packages/ui/src/components/layout/layout.tsx index 3d1bcca7..d72835c3 100644 --- a/packages/ui/src/components/layout/layout.tsx +++ b/packages/ui/src/components/layout/layout.tsx @@ -8,7 +8,7 @@ import { type Ref, type SetStateAction, use, - useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -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; @@ -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; @@ -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 () => { @@ -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(`[aria-controls="${el.id}"]`)?.focus(); + }, [item.open]); + return (
{ @@ -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" && } @@ -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) { if (!width || event.button !== 0) return; @@ -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", + )} >