mirror of
https://github.com/docmost/docmost.git
synced 2026-05-16 05:44:04 +08:00
fix(tree): move treeitem role to focusable row + aria-current
This commit is contained in:
@@ -294,10 +294,20 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
return null;
|
||||
})();
|
||||
|
||||
// The <li role="treeitem"> wrapper and recursion are owned by DocTree's
|
||||
// virtualizer now; this component renders only the row's body. ARIA state
|
||||
// (aria-expanded, aria-selected, aria-level) is set on the <li> itself —
|
||||
// the inner interactive element doesn't carry it.
|
||||
// Treeitem semantics ride on the row's focusable element (the consumer's
|
||||
// <a>). The outer <li> is presentational layout. aria-label uses the row's
|
||||
// label so the SR's accessible name is just the page title, not the
|
||||
// concatenation of inner action-button aria-labels.
|
||||
const treeItemProps = {
|
||||
role: 'treeitem' as const,
|
||||
'aria-level': level + 1,
|
||||
'aria-expanded': hasChildren ? isOpen : undefined,
|
||||
'aria-selected': isSelected ? (true as const) : undefined,
|
||||
'aria-current': isSelected ? ('page' as const) : undefined,
|
||||
'aria-label': getDragLabel(node),
|
||||
'data-row-id': node.id,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.rowWrapper}
|
||||
@@ -325,6 +335,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
isReceivingDrop: receivingDrop,
|
||||
rowRef,
|
||||
tabIndex: activeId === node.id ? 0 : -1,
|
||||
treeItemProps,
|
||||
toggleOpen,
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,20 @@ export type RenderRowProps<T extends object> = {
|
||||
// active row); every other row gets tabIndex={-1}. Consumers must spread
|
||||
// this onto the same element they wire rowRef to.
|
||||
tabIndex: 0 | -1;
|
||||
// Treeitem semantics for the row's focusable element. Consumers MUST spread
|
||||
// these onto the same element rowRef points at, so the focused element IS
|
||||
// the treeitem. This makes screen readers announce "treeitem" (not "link")
|
||||
// and replaces the descendant-text accname with the row's label, so action
|
||||
// button labels inside the row don't get concatenated.
|
||||
treeItemProps: {
|
||||
role: 'treeitem';
|
||||
'aria-level': number;
|
||||
'aria-expanded'?: boolean;
|
||||
'aria-selected'?: true;
|
||||
'aria-current'?: 'page';
|
||||
'aria-label': string;
|
||||
'data-row-id': string;
|
||||
};
|
||||
toggleOpen: () => void;
|
||||
};
|
||||
|
||||
@@ -480,23 +494,13 @@ function DocTreeInner<T extends object>(
|
||||
>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const row = flat[virtualItem.index];
|
||||
// aria-expanded belongs on the treeitem itself per the WAI-ARIA
|
||||
// pattern. Omitted entirely for leaf rows so screen readers don't
|
||||
// announce expand state for nodes that have no children.
|
||||
const nodeHasChildren =
|
||||
(row.node.children && row.node.children.length > 0) ||
|
||||
(row.node as { hasChildren?: boolean }).hasChildren === true;
|
||||
const ariaExpanded = nodeHasChildren
|
||||
? openIds.has(row.node.id)
|
||||
: undefined;
|
||||
return (
|
||||
<li
|
||||
key={row.node.id}
|
||||
role="treeitem"
|
||||
aria-level={row.level + 1}
|
||||
aria-expanded={ariaExpanded}
|
||||
aria-selected={row.node.id === selectedId ? true : undefined}
|
||||
data-row-id={row.node.id}
|
||||
// role="none" — the treeitem role lives on the focusable child
|
||||
// (the row's <a>), so screen readers announce "treeitem" on
|
||||
// navigation. The <li> is just layout glue.
|
||||
role="none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
||||
@@ -43,6 +43,7 @@ export function SpaceTreeRow({
|
||||
toggleOpen,
|
||||
rowRef,
|
||||
tabIndex,
|
||||
treeItemProps,
|
||||
readOnly,
|
||||
}: SpaceTreeRowProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -141,6 +142,7 @@ export function SpaceTreeRow({
|
||||
to={pageUrl}
|
||||
className={classes.node}
|
||||
tabIndex={tabIndex}
|
||||
{...treeItemProps}
|
||||
onClick={() => {
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
|
||||
@@ -117,6 +117,7 @@ function SharedTreeRow({
|
||||
isSelected,
|
||||
rowRef,
|
||||
tabIndex,
|
||||
treeItemProps,
|
||||
toggleOpen,
|
||||
}: RenderRowProps<SharedPageTreeNode>) {
|
||||
const { shareId } = useParams();
|
||||
@@ -133,6 +134,7 @@ function SharedTreeRow({
|
||||
<Box
|
||||
ref={rowRef as React.Ref<HTMLAnchorElement>}
|
||||
tabIndex={tabIndex}
|
||||
{...treeItemProps}
|
||||
data-selected={isSelected || undefined}
|
||||
className={clsx(classes.node, styles.treeNode)}
|
||||
component={Link}
|
||||
|
||||
Reference in New Issue
Block a user