authorgravatar for andrew@johnson5.netAndrew Johnson <andrew@johnson5.net> 2026-09-04 22:39:59-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 19:39:59-07:00
log7f8cf30ca479474f8c6e0a85f6e0a1169bcca365
treeb49cfac94f78659edd12bfd01b9124457b5de724
parent1246146f59a5a0f7a6f2da46bdd91f79b231d6a6
signature Signed by PGP key B5690EEEBB952194

fix(web): make sidebar project actions reachable by keyboard and screen reader (#5521)

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

1 files changed, 40 insertions(+), 2 deletions(-)

apps/web/src/components/Sidebar.tsx+40-2
...@@ -2223,10 +2223,18 @@ export default function Sidebar() {...@@ -2223,10 +2223,18 @@ export default function Sidebar() {
2223 },2223 },
2224 [isMobile, router, setOpenMobile],2224 [isMobile, router, setOpenMobile],
2225 );2225 );
2226 // Safari can send a click after Ctrl+click opens settings. Ignore that one
2227 // selection, then clear the guard when the picker opens again.
2228 const suppressNextScopeChangeRef = useRef(false);
2229 const highlightedProjectScopeKeyRef = useRef<string | null>(null);
2226 const handleProjectSettings = useCallback(2230 const handleProjectSettings = useCallback(
2227 (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {2231 (
2232 event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLInputElement>,
2233 projectGroup: SidebarProjectSnapshot,
2234 ) => {
2228 event.preventDefault();2235 event.preventDefault();
2229 event.stopPropagation();2236 event.stopPropagation();
2237 suppressNextScopeChangeRef.current = true;
2230 dispatchProjectScopeMenu({ type: "project-settings-opened" });2238 dispatchProjectScopeMenu({ type: "project-settings-opened" });
2231 openProjectSettings(projectGroup);2239 openProjectSettings(projectGroup);
2232 },2240 },
...@@ -3724,10 +3732,18 @@ export default function Sidebar() {...@@ -3724,10 +3732,18 @@ export default function Sidebar() {
3724 isItemEqualToValue={(a, b) => a.value === b.value}3732 isItemEqualToValue={(a, b) => a.value === b.value}
3725 open={projectScopeMenuState.open}3733 open={projectScopeMenuState.open}
3726 onOpenChange={(open) => {3734 onOpenChange={(open) => {
3735 if (open) suppressNextScopeChangeRef.current = false;
3727 dispatchProjectScopeMenu({ type: "open-changed", open });3736 dispatchProjectScopeMenu({ type: "open-changed", open });
3728 }}3737 }}
3738 onItemHighlighted={(item) => {
3739 highlightedProjectScopeKeyRef.current = item?.value ?? null;
3740 }}
3729 value={selectedProjectScopeItem}3741 value={selectedProjectScopeItem}
3730 onValueChange={(item) => {3742 onValueChange={(item) => {
3743 if (suppressNextScopeChangeRef.current) {
3744 suppressNextScopeChangeRef.current = false;
3745 return;
3746 }
3731 if (!item) return;3747 if (!item) return;
3732 setProjectScopeKey(item.value === "all" ? null : item.value);3748 setProjectScopeKey(item.value === "all" ? null : item.value);
3733 }}3749 }}
...@@ -3778,6 +3794,24 @@ export default function Sidebar() {...@@ -3778,6 +3794,24 @@ export default function Sidebar() {
3778 size="sm"3794 size="sm"
3779 unstyled3795 unstyled
3780 value={projectScopeMenuState.query}3796 value={projectScopeMenuState.query}
3797 onKeyDown={(event) => {
3798 if (
3799 event.defaultPrevented ||
3800 event.nativeEvent.isComposing ||
3801 event.ctrlKey ||
3802 event.altKey ||
3803 event.metaKey ||
3804 (event.key !== "ContextMenu" &&
3805 !(event.shiftKey && event.key === "F10"))
3806 ) {
3807 return;
3808 }
3809 // Combobox items use virtual focus: keyboard events
3810 // stay on this input, not on the highlighted option.
3811 const scopeKey = highlightedProjectScopeKeyRef.current;
3812 const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null;
3813 if (project) handleProjectSettings(event, project);
3814 }}
3781 onChange={(event) =>3815 onChange={(event) =>
3782 dispatchProjectScopeMenu({3816 dispatchProjectScopeMenu({
3783 type: "query-changed",3817 type: "query-changed",
...@@ -3798,6 +3832,9 @@ export default function Sidebar() {...@@ -3798,6 +3832,9 @@ export default function Sidebar() {
3798 value={item}3832 value={item}
3799 className="h-8 min-h-8 py-0 font-medium"3833 className="h-8 min-h-8 py-0 font-medium"
3800 contentClassName="flex min-w-0 items-center gap-2"3834 contentClassName="flex min-w-0 items-center gap-2"
3835 onContextMenu={(event) => {
3836 if (project) handleProjectSettings(event, project);
3837 }}
3801 >3838 >
3802 {project ? (3839 {project ? (
3803 <ProjectFavicon3840 <ProjectFavicon
...@@ -3816,7 +3853,8 @@ export default function Sidebar() {...@@ -3816,7 +3853,8 @@ export default function Sidebar() {
3816 <Button3853 <Button
3817 size="icon-xs"3854 size="icon-xs"
3818 variant="ghost-muted"3855 variant="ghost-muted"
3819 aria-label={`Project settings for ${project.displayName}`}3856 tabIndex={-1}
3857 aria-hidden="true"
3820 title={`Project settings for ${project.displayName}`}3858 title={`Project settings for ${project.displayName}`}
3821 className="ml-auto size-6 [--control-icon-color:currentColor] text-icon-muted focus-visible:bg-accent focus-visible:text-foreground"3859 className="ml-auto size-6 [--control-icon-color:currentColor] text-icon-muted focus-visible:bg-accent focus-visible:text-foreground"
3822 onPointerDown={(event) => event.stopPropagation()}3860 onPointerDown={(event) => event.stopPropagation()}