mirror of
https://github.com/docmost/docmost.git
synced 2026-05-20 16:44:05 +08:00
feat(tree): Space activation and ARIA refinements
This commit is contained in:
@@ -294,14 +294,10 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
|||||||
return null;
|
return null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// aria-expanded on the consumer's interactive element is enough; we drop
|
|
||||||
// aria-controls since the virtualized children no longer live inside a
|
|
||||||
// dedicated <ul role="group"> with a stable id (children are siblings in the
|
|
||||||
// flat virtualized list, not a DOM subtree).
|
|
||||||
const ariaProps = hasChildren ? { 'aria-expanded': isOpen } : {};
|
|
||||||
|
|
||||||
// The <li role="treeitem"> wrapper and recursion are owned by DocTree's
|
// The <li role="treeitem"> wrapper and recursion are owned by DocTree's
|
||||||
// virtualizer now; this component renders only the row's body.
|
// 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.
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.rowWrapper}
|
className={styles.rowWrapper}
|
||||||
@@ -328,7 +324,6 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
|||||||
isDragging,
|
isDragging,
|
||||||
isReceivingDrop: receivingDrop,
|
isReceivingDrop: receivingDrop,
|
||||||
rowRef,
|
rowRef,
|
||||||
ariaProps,
|
|
||||||
tabIndex: activeId === node.id ? 0 : -1,
|
tabIndex: activeId === node.id ? 0 : -1,
|
||||||
toggleOpen,
|
toggleOpen,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ export type RenderRowProps<T extends object> = {
|
|||||||
isReceivingDrop: 'before' | 'after' | 'make-child' | null;
|
isReceivingDrop: 'before' | 'after' | 'make-child' | null;
|
||||||
|
|
||||||
rowRef: Ref<HTMLElement>;
|
rowRef: Ref<HTMLElement>;
|
||||||
ariaProps: {
|
|
||||||
'aria-expanded'?: boolean;
|
|
||||||
'aria-controls'?: string;
|
|
||||||
};
|
|
||||||
// Roving tabindex: exactly one row in the tree carries tabIndex={0} (the
|
// Roving tabindex: exactly one row in the tree carries tabIndex={0} (the
|
||||||
// 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.
|
||||||
@@ -57,6 +53,11 @@ export type DocTreeProps<T extends object> = {
|
|||||||
|
|
||||||
getDragLabel: (node: TreeNode<T>) => string;
|
getDragLabel: (node: TreeNode<T>) => string;
|
||||||
uniqueContextId?: symbol;
|
uniqueContextId?: symbol;
|
||||||
|
|
||||||
|
// Accessible name for the tree itself (e.g. "Pages"). Rendered as
|
||||||
|
// aria-label on the <ul role="tree"> so screen readers announce what
|
||||||
|
// collection of items the user has entered.
|
||||||
|
'aria-label'?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DocTreeApi = {
|
export type DocTreeApi = {
|
||||||
@@ -117,6 +118,7 @@ function DocTreeInner<T extends object>(
|
|||||||
getDragLabel,
|
getDragLabel,
|
||||||
uniqueContextId,
|
uniqueContextId,
|
||||||
emptyState,
|
emptyState,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -278,10 +280,15 @@ function DocTreeInner<T extends object>(
|
|||||||
// pattern). Allowed with Shift since on most keyboards Shift+8 is how
|
// pattern). Allowed with Shift since on most keyboards Shift+8 is how
|
||||||
// "*" is produced. Handled separately from typeahead.
|
// "*" is produced. Handled separately from typeahead.
|
||||||
const isStarKey = e.key === '*';
|
const isStarKey = e.key === '*';
|
||||||
|
// Space activates the focused row — same effect as clicking it. Native
|
||||||
|
// <a> doesn't get this for free (only <button> does), so we wire it up
|
||||||
|
// explicitly to satisfy the WAI-ARIA tree pattern.
|
||||||
|
const isActivateKey = e.key === ' ';
|
||||||
// Single printable character → typeahead. e.key.length === 1 excludes
|
// Single printable character → typeahead. e.key.length === 1 excludes
|
||||||
// multi-char names like "ArrowDown", "Enter", "Tab", etc.
|
// multi-char names like "ArrowDown", "Enter", "Tab", etc.
|
||||||
const isTypeahead = e.key.length === 1 && !isNavKey && !isStarKey;
|
const isTypeahead =
|
||||||
if (!isNavKey && !isTypeahead && !isStarKey) return;
|
e.key.length === 1 && !isNavKey && !isStarKey && !isActivateKey;
|
||||||
|
if (!isNavKey && !isTypeahead && !isStarKey && !isActivateKey) return;
|
||||||
|
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.matches('input, textarea, [contenteditable="true"]')) return;
|
if (target.matches('input, textarea, [contenteditable="true"]')) return;
|
||||||
@@ -305,6 +312,19 @@ function DocTreeInner<T extends object>(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Space activates the focused row by synthesizing a click on the
|
||||||
|
// registered row element (its <a> Link). Skip if focus is on an inner
|
||||||
|
// button (chevron, +, menu) — those handle Space via native button
|
||||||
|
// semantics, and intercepting here would block their default behavior.
|
||||||
|
if (isActivateKey) {
|
||||||
|
const registered = rowElementsRef.current.get(id);
|
||||||
|
if (target === registered) {
|
||||||
|
e.preventDefault();
|
||||||
|
registered.click();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Typeahead: accumulate printable chars, jump to next row whose label
|
// Typeahead: accumulate printable chars, jump to next row whose label
|
||||||
// starts with the buffer. Same-letter presses cycle through matches; a
|
// starts with the buffer. Same-letter presses cycle through matches; a
|
||||||
// multi-char buffer searches from the current row so the user can
|
// multi-char buffer searches from the current row so the user can
|
||||||
@@ -447,6 +467,7 @@ function DocTreeInner<T extends object>(
|
|||||||
<div ref={scrollRef} className={styles.treeContainer}>
|
<div ref={scrollRef} className={styles.treeContainer}>
|
||||||
<ul
|
<ul
|
||||||
role="tree"
|
role="tree"
|
||||||
|
aria-label={ariaLabel}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={handleFocusIn}
|
onFocus={handleFocusIn}
|
||||||
style={{
|
style={{
|
||||||
@@ -459,11 +480,22 @@ 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="treeitem"
|
||||||
aria-level={row.level + 1}
|
aria-level={row.level + 1}
|
||||||
|
aria-expanded={ariaExpanded}
|
||||||
|
aria-selected={row.node.id === selectedId ? true : undefined}
|
||||||
data-row-id={row.node.id}
|
data-row-id={row.node.id}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export function SpaceTreeRow({
|
|||||||
hasChildren,
|
hasChildren,
|
||||||
toggleOpen,
|
toggleOpen,
|
||||||
rowRef,
|
rowRef,
|
||||||
ariaProps,
|
|
||||||
tabIndex,
|
tabIndex,
|
||||||
readOnly,
|
readOnly,
|
||||||
}: SpaceTreeRowProps) {
|
}: SpaceTreeRowProps) {
|
||||||
@@ -142,7 +141,6 @@ export function SpaceTreeRow({
|
|||||||
to={pageUrl}
|
to={pageUrl}
|
||||||
className={classes.node}
|
className={classes.node}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
{...ariaProps}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (mobileSidebarOpened) {
|
if (mobileSidebarOpened) {
|
||||||
toggleMobileSidebar();
|
toggleMobileSidebar();
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
disableDrag={disableDragDrop}
|
disableDrag={disableDragDrop}
|
||||||
disableDrop={disableDragDrop}
|
disableDrop={disableDragDrop}
|
||||||
getDragLabel={getDragLabel}
|
getDragLabel={getDragLabel}
|
||||||
|
aria-label={t("Pages")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface SharedTreeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SharedTree({ sharedPageTree }: SharedTreeProps) {
|
export default function SharedTree({ sharedPageTree }: SharedTreeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const treeRef = useRef<DocTreeApi | null>(null);
|
const treeRef = useRef<DocTreeApi | null>(null);
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const [openTreeNodes, setOpenTreeNodes] = useAtom(openSharedTreeNodesAtom);
|
const [openTreeNodes, setOpenTreeNodes] = useAtom(openSharedTreeNodesAtom);
|
||||||
@@ -100,6 +101,7 @@ export default function SharedTree({ sharedPageTree }: SharedTreeProps) {
|
|||||||
onMove={noopMove}
|
onMove={noopMove}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
getDragLabel={getDragLabel}
|
getDragLabel={getDragLabel}
|
||||||
|
aria-label={t("Pages")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -114,7 +116,6 @@ function SharedTreeRow({
|
|||||||
hasChildren,
|
hasChildren,
|
||||||
isSelected,
|
isSelected,
|
||||||
rowRef,
|
rowRef,
|
||||||
ariaProps,
|
|
||||||
tabIndex,
|
tabIndex,
|
||||||
toggleOpen,
|
toggleOpen,
|
||||||
}: RenderRowProps<SharedPageTreeNode>) {
|
}: RenderRowProps<SharedPageTreeNode>) {
|
||||||
@@ -131,7 +132,6 @@ function SharedTreeRow({
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={rowRef as React.Ref<HTMLAnchorElement>}
|
ref={rowRef as React.Ref<HTMLAnchorElement>}
|
||||||
{...ariaProps}
|
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
data-selected={isSelected || undefined}
|
data-selected={isSelected || undefined}
|
||||||
className={clsx(classes.node, styles.treeNode)}
|
className={clsx(classes.node, styles.treeNode)}
|
||||||
|
|||||||
Reference in New Issue
Block a user