mirror of
https://github.com/docmost/docmost.git
synced 2026-05-25 03:42:44 +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;
|
size?: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
c?: string;
|
c?: string;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +122,7 @@ function EmojiPicker({
|
|||||||
c={actionIconProps?.c || "gray"}
|
c={actionIconProps?.c || "gray"}
|
||||||
variant={actionIconProps?.variant || "transparent"}
|
variant={actionIconProps?.variant || "transparent"}
|
||||||
size={actionIconProps?.size}
|
size={actionIconProps?.size}
|
||||||
|
tabIndex={actionIconProps?.tabIndex}
|
||||||
onClick={handlers.toggle}
|
onClick={handlers.toggle}
|
||||||
aria-label={t("Pick emoji")}
|
aria-label={t("Pick emoji")}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type Props<T extends object> = {
|
|||||||
isLastSibling: boolean;
|
isLastSibling: boolean;
|
||||||
openIds: ReadonlySet<string>;
|
openIds: ReadonlySet<string>;
|
||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
|
// Roving tabindex: the single row that currently carries tabIndex={0}.
|
||||||
|
activeId?: string;
|
||||||
renderRow: (props: RenderRowProps<T>) => ReactNode;
|
renderRow: (props: RenderRowProps<T>) => ReactNode;
|
||||||
indentPerLevel: number;
|
indentPerLevel: number;
|
||||||
onMove: (sourceId: string, op: DropOp) => void | Promise<void>;
|
onMove: (sourceId: string, op: DropOp) => void | Promise<void>;
|
||||||
@@ -62,6 +64,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
|||||||
isLastSibling,
|
isLastSibling,
|
||||||
openIds,
|
openIds,
|
||||||
selectedId,
|
selectedId,
|
||||||
|
activeId,
|
||||||
renderRow,
|
renderRow,
|
||||||
indentPerLevel,
|
indentPerLevel,
|
||||||
onMove,
|
onMove,
|
||||||
@@ -326,6 +329,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
|||||||
isReceivingDrop: receivingDrop,
|
isReceivingDrop: receivingDrop,
|
||||||
rowRef,
|
rowRef,
|
||||||
ariaProps,
|
ariaProps,
|
||||||
|
tabIndex: activeId === node.id ? 0 : -1,
|
||||||
toggleOpen,
|
toggleOpen,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -373,6 +377,11 @@ function arePropsEqual<T extends object>(
|
|||||||
const wasSelected = prev.selectedId === id;
|
const wasSelected = prev.selectedId === id;
|
||||||
const isSelected = next.selectedId === id;
|
const isSelected = next.selectedId === id;
|
||||||
if (wasSelected !== isSelected) return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import {
|
|||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type Ref,
|
type Ref,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
|
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
|
||||||
import type { TreeNode, DropOp } from '../model/tree-model.types';
|
import type { TreeNode, DropOp } from '../model/tree-model.types';
|
||||||
|
import { treeModel } from '../model/tree-model';
|
||||||
import { DocTreeRow } from './doc-tree-row';
|
import { DocTreeRow } from './doc-tree-row';
|
||||||
import styles from '../styles/tree.module.css';
|
import styles from '../styles/tree.module.css';
|
||||||
|
|
||||||
@@ -28,6 +30,10 @@ export type RenderRowProps<T extends object> = {
|
|||||||
'aria-expanded'?: boolean;
|
'aria-expanded'?: boolean;
|
||||||
'aria-controls'?: string;
|
'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;
|
toggleOpen: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,6 +128,10 @@ function DocTreeInner<T extends object>(
|
|||||||
// ~500ms of no typing. Refs only — no re-render needed per keystroke.
|
// ~500ms of no typing. Refs only — no re-render needed per keystroke.
|
||||||
const typeaheadBufferRef = useRef('');
|
const typeaheadBufferRef = useRef('');
|
||||||
const typeaheadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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(
|
const contextId = useMemo(
|
||||||
() => uniqueContextId ?? Symbol('doc-tree'),
|
() => uniqueContextId ?? Symbol('doc-tree'),
|
||||||
[uniqueContextId],
|
[uniqueContextId],
|
||||||
@@ -157,6 +167,20 @@ function DocTreeInner<T extends object>(
|
|||||||
[data, openIds],
|
[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({
|
const virtualizer = useVirtualizer({
|
||||||
count: flat.length,
|
count: flat.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
@@ -250,10 +274,14 @@ function DocTreeInner<T extends object>(
|
|||||||
e.key === 'ArrowRight' ||
|
e.key === 'ArrowRight' ||
|
||||||
e.key === 'Home' ||
|
e.key === 'Home' ||
|
||||||
e.key === 'End');
|
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
|
// 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;
|
const isTypeahead = e.key.length === 1 && !isNavKey && !isStarKey;
|
||||||
if (!isNavKey && !isTypeahead) return;
|
if (!isNavKey && !isTypeahead && !isStarKey) 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;
|
||||||
@@ -315,6 +343,26 @@ function DocTreeInner<T extends object>(
|
|||||||
(row.node as { hasChildren?: boolean }).hasChildren === true;
|
(row.node as { hasChildren?: boolean }).hasChildren === true;
|
||||||
const isOpen = openIds.has(row.node.id);
|
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) {
|
switch (e.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault();
|
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) {
|
if (data.length === 0 && emptyState) {
|
||||||
return <div className={styles.treeContainer}>{emptyState}</div>;
|
return <div className={styles.treeContainer}>{emptyState}</div>;
|
||||||
}
|
}
|
||||||
@@ -387,6 +448,7 @@ function DocTreeInner<T extends object>(
|
|||||||
<ul
|
<ul
|
||||||
role="tree"
|
role="tree"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleFocusIn}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: totalSize,
|
height: totalSize,
|
||||||
@@ -417,6 +479,7 @@ function DocTreeInner<T extends object>(
|
|||||||
isLastSibling={row.isLastSibling}
|
isLastSibling={row.isLastSibling}
|
||||||
openIds={openIds}
|
openIds={openIds}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
|
activeId={effectiveActiveId}
|
||||||
renderRow={renderRow}
|
renderRow={renderRow}
|
||||||
indentPerLevel={indentPerLevel}
|
indentPerLevel={indentPerLevel}
|
||||||
onMove={onMove}
|
onMove={onMove}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
variant="transparent"
|
variant="transparent"
|
||||||
c="gray"
|
c="gray"
|
||||||
aria-label={t("Page menu")}
|
aria-label={t("Page menu")}
|
||||||
|
tabIndex={-1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function SpaceTreeRow({
|
|||||||
toggleOpen,
|
toggleOpen,
|
||||||
rowRef,
|
rowRef,
|
||||||
ariaProps,
|
ariaProps,
|
||||||
|
tabIndex,
|
||||||
readOnly,
|
readOnly,
|
||||||
}: SpaceTreeRowProps) {
|
}: SpaceTreeRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -140,6 +141,7 @@ export function SpaceTreeRow({
|
|||||||
ref={rowRef as React.Ref<HTMLAnchorElement>}
|
ref={rowRef as React.Ref<HTMLAnchorElement>}
|
||||||
to={pageUrl}
|
to={pageUrl}
|
||||||
className={classes.node}
|
className={classes.node}
|
||||||
|
tabIndex={tabIndex}
|
||||||
{...ariaProps}
|
{...ariaProps}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (mobileSidebarOpened) {
|
if (mobileSidebarOpened) {
|
||||||
@@ -163,6 +165,7 @@ export function SpaceTreeRow({
|
|||||||
}
|
}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
removeEmojiAction={handleRemoveEmoji}
|
removeEmojiAction={handleRemoveEmoji}
|
||||||
|
actionIconProps={{ tabIndex: -1 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -220,6 +223,7 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
|
|||||||
c="gray"
|
c="gray"
|
||||||
aria-label={isOpen ? t("Collapse") : t("Expand")}
|
aria-label={isOpen ? t("Collapse") : t("Expand")}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
|
tabIndex={-1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -271,6 +275,7 @@ function CreateNode({
|
|||||||
variant="transparent"
|
variant="transparent"
|
||||||
c="gray"
|
c="gray"
|
||||||
aria-label={t("Create page")}
|
aria-label={t("Create page")}
|
||||||
|
tabIndex={-1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ function SharedTreeRow({
|
|||||||
isSelected,
|
isSelected,
|
||||||
rowRef,
|
rowRef,
|
||||||
ariaProps,
|
ariaProps,
|
||||||
|
tabIndex,
|
||||||
toggleOpen,
|
toggleOpen,
|
||||||
}: RenderRowProps<SharedPageTreeNode>) {
|
}: RenderRowProps<SharedPageTreeNode>) {
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
@@ -131,6 +132,7 @@ function SharedTreeRow({
|
|||||||
<Box
|
<Box
|
||||||
ref={rowRef as React.Ref<HTMLAnchorElement>}
|
ref={rowRef as React.Ref<HTMLAnchorElement>}
|
||||||
{...ariaProps}
|
{...ariaProps}
|
||||||
|
tabIndex={tabIndex}
|
||||||
data-selected={isSelected || undefined}
|
data-selected={isSelected || undefined}
|
||||||
className={clsx(classes.node, styles.treeNode)}
|
className={clsx(classes.node, styles.treeNode)}
|
||||||
component={Link}
|
component={Link}
|
||||||
@@ -156,6 +158,7 @@ function SharedTreeRow({
|
|||||||
}
|
}
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
removeEmojiAction={() => {}}
|
removeEmojiAction={() => {}}
|
||||||
|
actionIconProps={{ tabIndex: -1 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={classes.text}>{node.name || t("untitled")}</span>
|
<span className={classes.text}>{node.name || t("untitled")}</span>
|
||||||
@@ -198,6 +201,7 @@ function SharedPageArrow({
|
|||||||
size={20}
|
size={20}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
c="gray"
|
c="gray"
|
||||||
|
tabIndex={-1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
Reference in New Issue
Block a user