mirror of
https://github.com/docmost/docmost.git
synced 2026-05-20 16:44:05 +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;
|
return null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// The <li role="treeitem"> wrapper and recursion are owned by DocTree's
|
// Treeitem semantics ride on the row's focusable element (the consumer's
|
||||||
// virtualizer now; this component renders only the row's body. ARIA state
|
// <a>). The outer <li> is presentational layout. aria-label uses the row's
|
||||||
// (aria-expanded, aria-selected, aria-level) is set on the <li> itself —
|
// label so the SR's accessible name is just the page title, not the
|
||||||
// the inner interactive element doesn't carry it.
|
// 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.rowWrapper}
|
className={styles.rowWrapper}
|
||||||
@@ -325,6 +335,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
|||||||
isReceivingDrop: receivingDrop,
|
isReceivingDrop: receivingDrop,
|
||||||
rowRef,
|
rowRef,
|
||||||
tabIndex: activeId === node.id ? 0 : -1,
|
tabIndex: activeId === node.id ? 0 : -1,
|
||||||
|
treeItemProps,
|
||||||
toggleOpen,
|
toggleOpen,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ export type RenderRowProps<T extends object> = {
|
|||||||
// active row); every other row gets tabIndex={-1}. Consumers must spread
|
// active row); every other row gets tabIndex={-1}. Consumers must spread
|
||||||
// this onto the same element they wire rowRef to.
|
// this onto the same element they wire rowRef to.
|
||||||
tabIndex: 0 | -1;
|
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;
|
toggleOpen: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -480,23 +494,13 @@ function DocTreeInner<T extends object>(
|
|||||||
>
|
>
|
||||||
{virtualItems.map((virtualItem) => {
|
{virtualItems.map((virtualItem) => {
|
||||||
const row = flat[virtualItem.index];
|
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 (
|
return (
|
||||||
<li
|
<li
|
||||||
key={row.node.id}
|
key={row.node.id}
|
||||||
role="treeitem"
|
// role="none" — the treeitem role lives on the focusable child
|
||||||
aria-level={row.level + 1}
|
// (the row's <a>), so screen readers announce "treeitem" on
|
||||||
aria-expanded={ariaExpanded}
|
// navigation. The <li> is just layout glue.
|
||||||
aria-selected={row.node.id === selectedId ? true : undefined}
|
role="none"
|
||||||
data-row-id={row.node.id}
|
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function SpaceTreeRow({
|
|||||||
toggleOpen,
|
toggleOpen,
|
||||||
rowRef,
|
rowRef,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
|
treeItemProps,
|
||||||
readOnly,
|
readOnly,
|
||||||
}: SpaceTreeRowProps) {
|
}: SpaceTreeRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -141,6 +142,7 @@ export function SpaceTreeRow({
|
|||||||
to={pageUrl}
|
to={pageUrl}
|
||||||
className={classes.node}
|
className={classes.node}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
|
{...treeItemProps}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (mobileSidebarOpened) {
|
if (mobileSidebarOpened) {
|
||||||
toggleMobileSidebar();
|
toggleMobileSidebar();
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ function SharedTreeRow({
|
|||||||
isSelected,
|
isSelected,
|
||||||
rowRef,
|
rowRef,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
|
treeItemProps,
|
||||||
toggleOpen,
|
toggleOpen,
|
||||||
}: RenderRowProps<SharedPageTreeNode>) {
|
}: RenderRowProps<SharedPageTreeNode>) {
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
@@ -133,6 +134,7 @@ function SharedTreeRow({
|
|||||||
<Box
|
<Box
|
||||||
ref={rowRef as React.Ref<HTMLAnchorElement>}
|
ref={rowRef as React.Ref<HTMLAnchorElement>}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
|
{...treeItemProps}
|
||||||
data-selected={isSelected || undefined}
|
data-selected={isSelected || undefined}
|
||||||
className={clsx(classes.node, styles.treeNode)}
|
className={clsx(classes.node, styles.treeNode)}
|
||||||
component={Link}
|
component={Link}
|
||||||
|
|||||||
Reference in New Issue
Block a user