[WEB-4166] chore: projects app sidebar accessibility (#7115)

* chore: add ARIA attributes

* chore: add missing translations

* chore: add accessibility translations for multiple languages and configured store according to it

* chore: refactor translation file handling and introduce TranslationFiles enum

* fix: accessibility issues in workspace sidebar

---------

Co-authored-by: JayashTripathy <jayashtripathy371@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2025-05-28 00:58:22 +05:30 committed by GitHub
parent b4bc49971c
commit a3a580923c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 777 additions and 170 deletions

View file

@ -1,4 +1,6 @@
// plane utils
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
// helpers
import { getFileURL } from "@/helpers/file.helper";
@ -9,22 +11,27 @@ type Props = {
classNames?: string;
};
export const WorkspaceLogo = (props: Props) => (
<div
className={cn(
`relative grid h-6 w-6 flex-shrink-0 place-items-center uppercase ${
!props.logo && "rounded bg-custom-primary-500 text-white"
} ${props.classNames ? props.classNames : ""}`
)}
>
{props.logo && props.logo !== "" ? (
<img
src={getFileURL(props.logo)}
className="absolute left-0 top-0 h-full w-full rounded object-cover"
alt="Workspace Logo"
/>
) : (
(props.name?.charAt(0) ?? "...")
)}
</div>
);
export const WorkspaceLogo = observer((props: Props) => {
// translation
const { t } = useTranslation();
return (
<div
className={cn(
`relative grid h-6 w-6 flex-shrink-0 place-items-center uppercase ${
!props.logo && "rounded bg-custom-primary-500 text-white"
} ${props.classNames ? props.classNames : ""}`
)}
>
{props.logo && props.logo !== "" ? (
<img
src={getFileURL(props.logo)}
className="absolute left-0 top-0 h-full w-full rounded object-cover"
alt={t("aria_labels.projects_sidebar.workspace_logo")}
/>
) : (
(props.name?.[0] ?? "...")
)}
</div>
);
});

View file

@ -25,21 +25,17 @@ import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
export const SidebarDropdown = observer(() => {
const { t } = useTranslation();
// store hooks
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
const { data: currentUser } = useUser();
const {
// updateCurrentUser,
// isUserInstanceAdmin,
signOut,
} = useUser();
const { signOut } = useUser();
const { updateUserProfile } = useUserProfile();
const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
const isUserInstanceAdmin = false;
const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace();
// derived values
const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
const isUserInstanceAdmin = false;
// translation
const { t } = useTranslation();
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -87,6 +83,7 @@ export const SidebarDropdown = observer(() => {
"group/menu-button flex items-center justify-between gap-1 p-1 truncate rounded text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:outline-none",
{ "flex-grow": !sidebarCollapsed }
)}
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
>
<div className="flex-grow flex items-center gap-2 truncate">
<WorkspaceLogo logo={activeWorkspace?.logo_url} name={activeWorkspace?.name} />
@ -190,7 +187,11 @@ export const SidebarDropdown = observer(() => {
)}
</Menu>
<Menu as="div" className="relative flex-shrink-0">
<Menu.Button className="grid place-items-center outline-none" ref={setReferenceElement}>
<Menu.Button
className="grid place-items-center outline-none"
ref={setReferenceElement}
aria-label={t("aria_labels.projects_sidebar.open_user_menu")}
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}

View file

@ -17,10 +17,9 @@ import { useParams } from "next/navigation";
import { createRoot } from "react-dom/client";
import { PenSquare, Star, MoreHorizontal, ChevronRight, GripVertical } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane helpers
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
// ui
import { useTranslation } from "@plane/i18n";
import { IFavorite, InstructionType } from "@plane/types";
import { CustomMenu, Tooltip, DropIndicator, FavoriteFolderIcon, DragHandle } from "@plane/ui";
// helpers
@ -29,7 +28,7 @@ import { cn } from "@/helpers/common.helper";
import { useAppTheme } from "@/hooks/store";
import { useFavorite } from "@/hooks/store/use-favorite";
import { usePlatformOS } from "@/hooks/use-platform-os";
// constants
// local imports
import { FavoriteRoot } from "./favorite-items";
import { getCanDrop, getInstructionFromPayload } from "./favorites.helpers";
import { NewFavoriteFolder } from "./new-fav-folder";
@ -54,10 +53,11 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
const [isDragging, setIsDragging] = useState(false);
const [folderToRename, setFolderToRename] = useState<string | boolean | null>(null);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const elementRef = useRef<HTMLDivElement | null>(null);
// translation
const { t } = useTranslation();
useEffect(() => {
if (favorite.children === undefined && workspaceSlug) {
@ -231,11 +231,11 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="size-4" />
<MoreHorizontal className="size-3" />
</span>
}
menuButtonOnClick={() => setIsMenuActive(!isMenuActive)}
className={cn(
"opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
{
@ -244,6 +244,7 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
>
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
<span className="flex items-center justify-start gap-2">
@ -267,9 +268,12 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
"inline-block": isMenuActive,
}
)}
aria-label={t(
open ? "aria_labels.projects_sidebar.close_folder" : "aria_labels.projects_sidebar.open_folder"
)}
>
<ChevronRight
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
className={cn("size-3 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
"rotate-90": open,
})}
/>

View file

@ -1,8 +1,10 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
import { MoreHorizontal, Star } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { IFavorite } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
@ -15,19 +17,22 @@ type Props = {
handleRemoveFromFavorites: (favorite: IFavorite) => void;
};
export const FavoriteItemQuickAction: FC<Props> = (props) => {
export const FavoriteItemQuickAction: FC<Props> = observer((props) => {
const { ref, isMenuActive, onChange, handleRemoveFromFavorites, favorite } = props;
// translation
const { t } = useTranslation();
return (
<CustomMenu
customButton={
<span
ref={ref}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
onClick={() => onChange(!isMenuActive)}
>
<MoreHorizontal className="size-4" />
</span>
}
menuButtonOnClick={() => onChange(!isMenuActive)}
className={cn(
"opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
{
@ -36,6 +41,7 @@ export const FavoriteItemQuickAction: FC<Props> = (props) => {
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
>
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
<span className="flex items-center justify-start gap-2">
@ -45,4 +51,4 @@ export const FavoriteItemQuickAction: FC<Props> = (props) => {
</CustomMenu.MenuItem>
</CustomMenu>
);
};
});

View file

@ -34,13 +34,12 @@ import { getInstructionFromPayload, TargetData } from "./favorites.helpers";
import { NewFavoriteFolder } from "./new-fav-folder";
export const SidebarFavoritesMenu = observer(() => {
//state
// states
const [createNewFolder, setCreateNewFolder] = useState<boolean | string | null>(null);
const [isDragging, setIsDragging] = useState(false);
// navigation
const { workspaceSlug } = useParams();
// store hooks
const { t } = useTranslation();
const { sidebarCollapsed } = useAppTheme();
const {
favoriteIds,
@ -50,17 +49,17 @@ export const SidebarFavoritesMenu = observer(() => {
reOrderFavorite,
moveFavoriteToFolder,
} = useFavorite();
const { workspaceSlug } = useParams();
// translation
const { t } = useTranslation();
// platform hooks
const { isMobile } = usePlatformOS();
// local storage
const { setValue: toggleFavoriteMenu, storedValue } = useLocalStorage<boolean>(IS_FAVORITE_MENU_OPEN, false);
// derived values
const isFavoriteMenuOpen = !!storedValue;
// refs
const containerRef = useRef<HTMLDivElement | null>(null);
const elementRef = useRef(null);
const containerRef = useRef<HTMLDivElement>(null);
const elementRef = useRef<HTMLDivElement>(null);
const handleMoveToFolder = (sourceId: string, destinationId: string) => {
moveFavoriteToFolder(workspaceSlug.toString(), sourceId, {
@ -131,6 +130,7 @@ export const SidebarFavoritesMenu = observer(() => {
});
});
};
const handleRemoveFromFavoritesFolder = (favoriteId: string) => {
removeFromFavoriteFolder(workspaceSlug.toString(), favoriteId).catch(() => {
setToast({
@ -151,7 +151,7 @@ export const SidebarFavoritesMenu = observer(() => {
});
});
},
[workspaceSlug, reOrderFavorite]
[workspaceSlug, reOrderFavorite, t]
);
useEffect(() => {
@ -190,37 +190,68 @@ export const SidebarFavoritesMenu = observer(() => {
<>
<Disclosure as="div" defaultOpen ref={containerRef}>
{!sidebarCollapsed && (
<Disclosure.Button
<div
ref={elementRef}
as="button"
className={cn(
"sticky top-0 bg-custom-sidebar-background-100 z-10 group/workspace-button w-full px-2 py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90 rounded text-sm font-semibold",
"group/favorites-button w-full flex items-center justify-between px-2 py-1.5 rounded text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90",
{
"bg-custom-sidebar-background-80 opacity-60": isDragging,
"p-0 justify-center w-fit mx-auto bg-custom-sidebar-background-90 hover:bg-custom-sidebar-background-80":
sidebarCollapsed,
}
)}
>
<span onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)} className="flex-1 text-start">
{t("favorites")}
</span>
<span className="flex flex-shrink-0 opacity-0 pointer-events-none group-hover/workspace-button:opacity-100 group-hover/workspace-button:pointer-events-auto rounded p-0.5 ">
<Disclosure.Button
as="button"
type="button"
className={cn(
"w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400",
{
"!text-center w-8 px-2 py-1.5 justify-center": sidebarCollapsed,
"bg-custom-sidebar-background-80 opacity-60": isDragging,
}
)}
onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)}
aria-label={t(
isFavoriteMenuOpen
? "aria_labels.projects_sidebar.close_favorites_menu"
: "aria_labels.projects_sidebar.open_favorites_menu"
)}
>
<span className="text-sm font-semibold">{t("favorites")}</span>
</Disclosure.Button>
<div className="flex items-center opacity-0 pointer-events-none group-hover/favorites-button:opacity-100 group-hover/favorites-button:pointer-events-auto">
<Tooltip tooltipHeading={t("create_folder")} tooltipContent="">
<FolderPlus
<button
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0 grid place-items-center"
onClick={() => {
setCreateNewFolder(true);
if (!isFavoriteMenuOpen) toggleFavoriteMenu(!isFavoriteMenuOpen);
}}
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform")}
/>
aria-label={t("aria_labels.projects_sidebar.create_favorites_folder")}
>
<FolderPlus className="size-3" />
</button>
</Tooltip>
<ChevronRight
<Disclosure.Button
as="button"
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0 grid place-items-center"
onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)}
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
"rotate-90": isFavoriteMenuOpen,
})}
/>
</span>
</Disclosure.Button>
aria-label={t(
isFavoriteMenuOpen
? "aria_labels.projects_sidebar.close_favorites_menu"
: "aria_labels.projects_sidebar.open_favorites_menu"
)}
>
<ChevronRight
className={cn("flex-shrink-0 size-3 transition-all", {
"rotate-90": isFavoriteMenuOpen,
})}
/>
</Disclosure.Button>
</div>
</div>
)}
<Transition
show={isFavoriteMenuOpen}

View file

@ -134,7 +134,14 @@ export const NewFavoriteFolder = observer((props: TProps) => {
name="name"
control={control}
rules={{ required: true }}
render={({ field }) => <Input className="w-full" placeholder={t("new_folder")} {...field} />}
render={({ field }) => (
<Input
className="w-full"
placeholder={t("new_folder")}
aria-label={t("aria_labels.projects_sidebar.enter_folder_name")}
{...field}
/>
)}
/>
</form>
</div>

View file

@ -175,8 +175,13 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
isCollapsed ? "w-full" : ""
}`}
onClick={() => toggleSidebar()}
aria-label={t(
isCollapsed
? "aria_labels.projects_sidebar.expand_sidebar"
: "aria_labels.projects_sidebar.collapse_sidebar"
)}
>
<MoveLeft className={`h-4 w-4 duration-300 ${isCollapsed ? "rotate-180" : ""}`} />
<MoveLeft className={`size-4 duration-300 ${isCollapsed ? "rotate-180" : ""}`} />
</button>
</Tooltip>
</div>

View file

@ -184,13 +184,13 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
const sourceId = source?.data?.id as string | undefined;
const destinationId = self?.data?.id as string | undefined;
handleOnProjectDrop && handleOnProjectDrop(sourceId, destinationId, currentInstruction === "DRAG_BELOW");
handleOnProjectDrop?.(sourceId, destinationId, currentInstruction === "DRAG_BELOW");
highlightIssueOnDrop(`sidebar-${sourceId}-${projectListType}`);
},
})
);
}, [projectRef?.current, dragHandleRef?.current, projectId, isLastChild, projectListType, handleOnProjectDrop]);
}, [projectId, isLastChild, projectListType, handleOnProjectDrop]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
useOutsideClickDetector(projectRef, () => projectRef?.current?.classList?.remove(HIGHLIGHT_CLASS));
@ -284,6 +284,11 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", {
"justify-center": isSidebarCollapsed,
})}
aria-label={
isProjectListOpen
? t("aria_labels.projects_sidebar.close_project_menu")
: t("aria_labels.projects_sidebar.open_project_menu")
}
>
<div className="size-4 grid place-items-center flex-shrink-0">
<Logo logo={project.logo_props} size={16} />
@ -310,6 +315,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
useCaptureForOutsideClick
closeOnSelect
>
@ -384,6 +390,11 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
}
)}
onClick={() => setIsProjectListOpen(!isProjectListOpen)}
aria-label={t(
isProjectListOpen
? "aria_labels.projects_sidebar.close_project_menu"
: "aria_labels.projects_sidebar.open_project_menu"
)}
>
<ChevronRight
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {

View file

@ -167,12 +167,17 @@ export const SidebarProjectsList: FC = observer(() => {
as="button"
type="button"
className={cn(
"group w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400",
"w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400",
{
"!text-center w-8 px-2 py-1.5 justify-center": isCollapsed,
}
)}
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
aria-label={t(
isAllProjectsListOpen
? "aria_labels.projects_sidebar.close_projects_menu"
: "aria_labels.projects_sidebar.open_projects_menu"
)}
>
<Tooltip tooltipHeading={t("projects")} tooltipContent="" position="right" disabled={!isCollapsed}>
<>
@ -195,6 +200,7 @@ export const SidebarProjectsList: FC = observer(() => {
setTrackElement(`APP_SIDEBAR_JOINED_BLOCK`);
setIsProjectModalOpen(true);
}}
aria-label={t("aria_labels.projects_sidebar.create_new_project")}
>
<Plus className="size-3" />
</button>
@ -205,9 +211,14 @@ export const SidebarProjectsList: FC = observer(() => {
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
aria-label={t(
isAllProjectsListOpen
? "aria_labels.projects_sidebar.close_projects_menu"
: "aria_labels.projects_sidebar.open_projects_menu"
)}
>
<ChevronRight
className={cn("flex-shrink-0 size-4 transition-all", {
className={cn("flex-shrink-0 size-3 transition-all", {
"rotate-90": isAllProjectsListOpen,
})}
/>

View file

@ -46,7 +46,9 @@ export const SidebarQuickActions = observer(() => {
const handleMouseEnter = () => {
// if enter before time out clear the timeout
timeoutRef?.current && clearTimeout(timeoutRef.current);
if (timeoutRef?.current) {
clearTimeout(timeoutRef.current);
}
setIsDraftButtonOpen(true);
};

View file

@ -8,6 +8,7 @@ import {
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS,
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
// components
import { SidebarNavItem } from "@/components/sidebar";
@ -20,9 +21,10 @@ export const SidebarMenuItems = observer(() => {
// routers
const { workspaceSlug } = useParams();
// store hooks
const { sidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
const { sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
const { getNavigationPreferences } = useWorkspace();
// translation
const { t } = useTranslation();
// derived values
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
@ -39,31 +41,35 @@ export const SidebarMenuItems = observer(() => {
);
return (
<>
<div
className={cn("flex flex-col gap-0.5", {
"space-y-0": sidebarCollapsed,
})}
>
{WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS.map((item, _index) => (
<SidebarItem key={`static_${_index}`} item={item} />
))}
{sortedNavigationItems.map((item, _index) => (
<SidebarItem key={`dynamic_${_index}`} item={item} />
))}
<SidebarNavItem className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}>
<button
onClick={() => toggleExtendedSidebar()}
className={cn("flex items-center gap-1.5 text-sm font-medium flex-grow text-custom-text-350", {
"justify-center": sidebarCollapsed,
})}
id="extended-sidebar-toggle"
>
<Ellipsis className="size-4" />
{!sidebarCollapsed && <span>More</span>}
</button>
</SidebarNavItem>
</div>
</>
<div
className={cn("flex flex-col gap-0.5", {
"space-y-0": sidebarCollapsed,
})}
>
{WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS.map((item, _index) => (
<SidebarItem key={`static_${_index}`} item={item} />
))}
{sortedNavigationItems.map((item, _index) => (
<SidebarItem key={`dynamic_${_index}`} item={item} />
))}
<SidebarNavItem className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}>
<button
type="button"
onClick={() => toggleExtendedSidebar()}
className={cn("flex items-center gap-1.5 text-sm font-medium flex-grow text-custom-text-350", {
"justify-center": sidebarCollapsed,
})}
id="extended-sidebar-toggle"
aria-label={t(
extendedSidebarCollapsed
? "aria_labels.projects_sidebar.open_extended_sidebar"
: "aria_labels.projects_sidebar.close_extended_sidebar"
)}
>
<Ellipsis className="flex-shrink-0 size-4" />
{!sidebarCollapsed && <span>More</span>}
</button>
</SidebarNavItem>
</div>
);
});