mirror of
https://github.com/docmost/docmost.git
synced 2026-05-14 12:44:16 +08:00
feat(tree): roving tabindex and * to expand sibling subtrees
This commit is contained in:
@@ -34,6 +34,7 @@ export interface EmojiPickerInterface {
|
||||
size?: string;
|
||||
variant?: string;
|
||||
c?: string;
|
||||
tabIndex?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,6 +122,7 @@ function EmojiPicker({
|
||||
c={actionIconProps?.c || "gray"}
|
||||
variant={actionIconProps?.variant || "transparent"}
|
||||
size={actionIconProps?.size}
|
||||
tabIndex={actionIconProps?.tabIndex}
|
||||
onClick={handlers.toggle}
|
||||
aria-label={t("Pick emoji")}
|
||||
aria-haspopup="dialog"
|
||||
|
||||
@@ -36,6 +36,8 @@ type Props<T extends object> = {
|
||||
isLastSibling: boolean;
|
||||
openIds: ReadonlySet<string>;
|
||||
selectedId?: string;
|
||||
// Roving tabindex: the single row that currently carries tabIndex={0}.
|
||||
activeId?: string;
|
||||
renderRow: (props: RenderRowProps<T>) => ReactNode;
|
||||
indentPerLevel: number;
|
||||
onMove: (sourceId: string, op: DropOp) => void | Promise<void>;
|
||||
@@ -62,6 +64,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
isLastSibling,
|
||||
openIds,
|
||||
selectedId,
|
||||
activeId,
|
||||
renderRow,
|
||||
indentPerLevel,
|
||||
onMove,
|
||||
@@ -326,6 +329,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
isReceivingDrop: receivingDrop,
|
||||
rowRef,
|
||||
ariaProps,
|
||||
tabIndex: activeId === node.id ? 0 : -1,
|
||||
toggleOpen,
|
||||
})}
|
||||
</div>
|
||||
@@ -373,6 +377,11 @@ function arePropsEqual<T extends object>(
|
||||
const wasSelected = prev.selectedId === id;
|
||||
const isSelected = next.selectedId === id;
|
||||
if (wasSelected !== isSelected) return false;
|
||||
// activeId: same trick — only the outgoing and incoming active rows
|
||||
// re-render when the user moves focus through the tree.
|
||||
const wasActive = prev.activeId === id;
|
||||
const isActive = next.activeId === id;
|
||||
if (wasActive !== isActive) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
|
||||
import type { TreeNode, DropOp } from '../model/tree-model.types';
|
||||
import { treeModel } from '../model/tree-model';
|
||||
import { DocTreeRow } from './doc-tree-row';
|
||||
import styles from '../styles/tree.module.css';
|
||||
|
||||
@@ -28,6 +30,10 @@ export type RenderRowProps<T extends object> = {
|
||||
'aria-expanded'?: boolean;
|
||||
'aria-controls'?: string;
|
||||
};
|
||||
// Roving tabindex: exactly one row in the tree carries tabIndex={0} (the
|
||||
// active row); every other row gets tabIndex={-1}. Consumers must spread
|
||||
// this onto the same element they wire rowRef to.
|
||||
tabIndex: 0 | -1;
|
||||
toggleOpen: () => void;
|
||||
};
|
||||
|
||||
@@ -122,6 +128,10 @@ function DocTreeInner<T extends object>(
|
||||
// ~500ms of no typing. Refs only — no re-render needed per keystroke.
|
||||
const typeaheadBufferRef = useRef('');
|
||||
const typeaheadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Roving tabindex: the row most-recently focused by the user. Falls back
|
||||
// to selectedId, then to the first visible row, when the tracked id is
|
||||
// gone from the flat list (e.g. its branch was collapsed).
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined);
|
||||
const contextId = useMemo(
|
||||
() => uniqueContextId ?? Symbol('doc-tree'),
|
||||
[uniqueContextId],
|
||||
@@ -157,6 +167,20 @@ function DocTreeInner<T extends object>(
|
||||
[data, openIds],
|
||||
);
|
||||
|
||||
// Membership lookup for the flat list. Used to validate activeId/selectedId
|
||||
// before promoting them to the effective active row.
|
||||
const flatIds = useMemo(() => new Set(flat.map((r) => r.node.id)), [flat]);
|
||||
|
||||
// Effective active row for tabindex purposes. Prefers user-focused row,
|
||||
// then the currently selected page, then the first visible row. The user's
|
||||
// arrow / Home / End / typeahead navigation updates activeId via the focus
|
||||
// event delegated on the <ul>; explicit clicks also flow through focus.
|
||||
const effectiveActiveId = useMemo(() => {
|
||||
if (activeId && flatIds.has(activeId)) return activeId;
|
||||
if (selectedId && flatIds.has(selectedId)) return selectedId;
|
||||
return flat[0]?.node.id;
|
||||
}, [activeId, selectedId, flatIds, flat]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flat.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
@@ -250,10 +274,14 @@ function DocTreeInner<T extends object>(
|
||||
e.key === 'ArrowRight' ||
|
||||
e.key === 'Home' ||
|
||||
e.key === 'End');
|
||||
// Star expands all sibling subtrees of the focused row (WAI-ARIA tree
|
||||
// pattern). Allowed with Shift since on most keyboards Shift+8 is how
|
||||
// "*" is produced. Handled separately from typeahead.
|
||||
const isStarKey = e.key === '*';
|
||||
// Single printable character → typeahead. e.key.length === 1 excludes
|
||||
// multi-char names like "ArrowDown", "Enter", "Tab", etc.
|
||||
const isTypeahead = e.key.length === 1 && !isNavKey;
|
||||
if (!isNavKey && !isTypeahead) return;
|
||||
const isTypeahead = e.key.length === 1 && !isNavKey && !isStarKey;
|
||||
if (!isNavKey && !isTypeahead && !isStarKey) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.matches('input, textarea, [contenteditable="true"]')) return;
|
||||
@@ -315,6 +343,26 @@ function DocTreeInner<T extends object>(
|
||||
(row.node as { hasChildren?: boolean }).hasChildren === true;
|
||||
const isOpen = openIds.has(row.node.id);
|
||||
|
||||
// Asterisk: expand every sibling subtree at the focused row's level.
|
||||
// Walks the authoritative tree (not flat, which only carries visible
|
||||
// rows) so we also expand siblings whose own subtree is currently
|
||||
// collapsed. Focus and selection stay put per the WAI-ARIA pattern.
|
||||
if (isStarKey) {
|
||||
e.preventDefault();
|
||||
const info = treeModel.siblingsOf(rootDataRef.current, row.node.id);
|
||||
if (info) {
|
||||
for (const sib of info.siblings) {
|
||||
const sibHasChildren =
|
||||
(sib.children && sib.children.length > 0) ||
|
||||
(sib as { hasChildren?: boolean }).hasChildren === true;
|
||||
if (sibHasChildren && !openIds.has(sib.id)) {
|
||||
onToggle(sib.id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
@@ -375,6 +423,19 @@ function DocTreeInner<T extends object>(
|
||||
[],
|
||||
);
|
||||
|
||||
// Event-delegated focus tracking — when any descendant (a row's Link, or an
|
||||
// inner action button) gains focus, mark the enclosing row as active. Keeps
|
||||
// tabIndex aligned with the user's current position whether they got there
|
||||
// by click, arrow nav, or focusByIndex's programmatic .focus() call.
|
||||
const handleFocusIn = useCallback(
|
||||
(e: React.FocusEvent<HTMLUListElement>) => {
|
||||
const rowEl = (e.target as HTMLElement).closest('[data-row-id]');
|
||||
const id = rowEl?.getAttribute('data-row-id');
|
||||
if (id) setActiveId(id);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (data.length === 0 && emptyState) {
|
||||
return <div className={styles.treeContainer}>{emptyState}</div>;
|
||||
}
|
||||
@@ -387,6 +448,7 @@ function DocTreeInner<T extends object>(
|
||||
<ul
|
||||
role="tree"
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocusIn}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: totalSize,
|
||||
@@ -417,6 +479,7 @@ function DocTreeInner<T extends object>(
|
||||
isLastSibling={row.isLastSibling}
|
||||
openIds={openIds}
|
||||
selectedId={selectedId}
|
||||
activeId={effectiveActiveId}
|
||||
renderRow={renderRow}
|
||||
indentPerLevel={indentPerLevel}
|
||||
onMove={onMove}
|
||||
|
||||
@@ -126,6 +126,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
aria-label={t("Page menu")}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -43,6 +43,7 @@ export function SpaceTreeRow({
|
||||
toggleOpen,
|
||||
rowRef,
|
||||
ariaProps,
|
||||
tabIndex,
|
||||
readOnly,
|
||||
}: SpaceTreeRowProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -140,6 +141,7 @@ export function SpaceTreeRow({
|
||||
ref={rowRef as React.Ref<HTMLAnchorElement>}
|
||||
to={pageUrl}
|
||||
className={classes.node}
|
||||
tabIndex={tabIndex}
|
||||
{...ariaProps}
|
||||
onClick={() => {
|
||||
if (mobileSidebarOpened) {
|
||||
@@ -163,6 +165,7 @@ export function SpaceTreeRow({
|
||||
}
|
||||
readOnly={!canEdit}
|
||||
removeEmojiAction={handleRemoveEmoji}
|
||||
actionIconProps={{ tabIndex: -1 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -220,6 +223,7 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
|
||||
c="gray"
|
||||
aria-label={isOpen ? t("Collapse") : t("Expand")}
|
||||
aria-expanded={isOpen}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -271,6 +275,7 @@ function CreateNode({
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
aria-label={t("Create page")}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -115,6 +115,7 @@ function SharedTreeRow({
|
||||
isSelected,
|
||||
rowRef,
|
||||
ariaProps,
|
||||
tabIndex,
|
||||
toggleOpen,
|
||||
}: RenderRowProps<SharedPageTreeNode>) {
|
||||
const { shareId } = useParams();
|
||||
@@ -131,6 +132,7 @@ function SharedTreeRow({
|
||||
<Box
|
||||
ref={rowRef as React.Ref<HTMLAnchorElement>}
|
||||
{...ariaProps}
|
||||
tabIndex={tabIndex}
|
||||
data-selected={isSelected || undefined}
|
||||
className={clsx(classes.node, styles.treeNode)}
|
||||
component={Link}
|
||||
@@ -156,6 +158,7 @@ function SharedTreeRow({
|
||||
}
|
||||
readOnly={true}
|
||||
removeEmojiAction={() => {}}
|
||||
actionIconProps={{ tabIndex: -1 }}
|
||||
/>
|
||||
</div>
|
||||
<span className={classes.text}>{node.name || t("untitled")}</span>
|
||||
@@ -198,6 +201,7 @@ function SharedPageArrow({
|
||||
size={20}
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
Reference in New Issue
Block a user