Implement all the ARIA keyboard interactions, and selection follows focus

This commit is contained in:
Eric Allam
2022-03-18 10:05:32 +00:00
parent 084a93e03a
commit f9671c6df7
15 changed files with 364 additions and 56 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "typescript",
"tsconfig": "tsconfig.json",
"option": "watch",
"problemMatcher": ["$tsc-watch"],
"group": "build",
"label": "tsc: watch - tsconfig.json"
}
]
}
+1 -1
View File
@@ -54,7 +54,7 @@ export function ColumnItem({ item }: { item: ColumnViewNode }) {
return (
<div
className={`flex h-9 items-center justify-items-stretch mx-1 px-1 py-1 my-1 rounded-sm ${stateStyle}`}
onClick={() => goToNodeId(id)}
onClick={() => goToNodeId(id, "columnView")}
ref={htmlElement}
>
<div className="w-4 flex-none flex-col justify-items-center">
+1 -1
View File
@@ -63,7 +63,7 @@ function HomeInfoBoxSectionContent() {
useEffect(() => {
const selectedPath = infoBoxData[index].highlight;
api.goToNodeId(selectedPath);
api.goToNodeId(selectedPath, "home");
}, [index]);
const resetInterval = () => {
+1 -1
View File
@@ -72,7 +72,7 @@ export function JsonEditor() {
const path = JSONHeroPath.fromPointer(pointer);
goToNodeId(path.toString());
goToNodeId(path.toString(), "editor");
},
[goToNodeId]
);
+52 -11
View File
@@ -10,11 +10,12 @@ import { Body } from "./Primitives/Body";
import { Mono } from "./Primitives/Mono";
export function JsonTreeView() {
const { selectedNodeId } = useJsonColumnViewState();
const { selectedNodeId, selectedNodeSource } = useJsonColumnViewState();
const { goToNodeId } = useJsonColumnViewAPI();
const { tree, parentRef } = useJsonTreeViewContext();
// Scroll to the selected node when this component is first rendered.
const scrolledToNodeRef = useRef(false);
useEffect(() => {
@@ -24,6 +25,43 @@ export function JsonTreeView() {
}
}, [selectedNodeId, scrolledToNodeRef]);
// This focuses and scrolls to the selected node when the selectedNodeId
// is set from a source other than this tree (e.g. the search bar, path bar, related values).
useEffect(() => {
if (
tree.focusedNodeId &&
selectedNodeId &&
tree.focusedNodeId !== selectedNodeId
) {
if (selectedNodeSource !== "tree") {
if (selectedNodeId === "$") {
tree.focusFirst();
} else {
tree.focusNode(selectedNodeId);
tree.scrollToNode(selectedNodeId);
}
}
}
}, [tree.focusedNodeId, goToNodeId, selectedNodeId, selectedNodeSource]);
// This is what syncs the tree view's focused node to the column view selected node
const previousFocusedNodeId = useRef<string | null>(null);
useEffect(() => {
if (!previousFocusedNodeId.current) {
previousFocusedNodeId.current = tree.focusedNodeId;
return;
}
if (
tree.focusedNodeId &&
previousFocusedNodeId.current !== tree.focusedNodeId
) {
previousFocusedNodeId.current = tree.focusedNodeId;
goToNodeId(tree.focusedNodeId, "tree");
}
}, [previousFocusedNodeId, tree.focusedNodeId, tree.focusNode, goToNodeId]);
return (
<div
className="text-white w-full"
@@ -44,7 +82,6 @@ export function JsonTreeView() {
virtualNode={virtualNode}
key={virtualNode.node.id}
onToggle={() => tree.toggleNode(virtualNode.node.id)}
onClick={() => goToNodeId(virtualNode.node.id)}
selectedNodeId={selectedNodeId}
/>
))}
@@ -55,20 +92,29 @@ export function JsonTreeView() {
function TreeViewNode({
virtualNode,
onClick,
onToggle,
selectedNodeId,
}: {
virtualNode: VirtualNode<JsonTreeViewNode>;
selectedNodeId?: string;
onClick?: (node: JsonTreeViewNode) => void;
onToggle?: (node: JsonTreeViewNode) => void;
}) {
const { tree } = useJsonTreeViewContext();
const { node, virtualItem, depth } = virtualNode;
const indentClassName = computeTreeNodePaddingClass(depth);
const isSelected = selectedNodeId === node.id;
const isFocused = tree.focusedNodeId === node.id;
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (elementRef.current && isFocused) {
elementRef.current.focus();
}
}, [elementRef, isFocused]);
return (
<div
@@ -82,12 +128,7 @@ function TreeViewNode({
}}
key={virtualNode.node.id}
{...virtualNode.getItemProps()}
onClick={(e) => {
if (onClick) {
e.stopPropagation();
onClick(virtualNode.node);
}
}}
ref={elementRef}
>
<div
className={`h-full flex select-none ${
@@ -103,7 +144,7 @@ function TreeViewNode({
<span
onClick={(e) => {
if (onToggle) {
e.stopPropagation();
e.preventDefault();
onToggle(node);
}
}}
+1 -1
View File
@@ -49,7 +49,7 @@ export function PathBarLink({
: "hover:bg-slate-300 px-2 py-[3px] rounded-sm transition dark:hover:bg-white dark:hover:bg-opacity-[5%]"
}`}
style={{ flexShrink: 1 }}
onClick={() => goToNodeId(node.id)}
onClick={() => goToNodeId(node.id, "pathBar")}
>
<div className="w-4 flex-shrink-[0.5] flex-grow-0 flex-col justify-items-center whitespace-nowrap overflow-x-hidden transition dark:text-slate-400">
{node.icon && <node.icon className="h-3 w-3" />}
+2 -1
View File
@@ -81,7 +81,8 @@ export function PathPreview({
: "disabled"
}`}
onClick={() =>
isEnabled && goToNodeId(components[components.length - 1].id)
isEnabled &&
goToNodeId(components[components.length - 1].id, "relatedValues")
}
>
<div
+1 -1
View File
@@ -134,7 +134,7 @@ export function JsonColumnViewProvider({ children }: { children: ReactNode }) {
const restoredState = JSON.parse(storage) as ColumnViewInstanceState;
if (!restoredState.selectedNodeId) return;
api.goToNodeId(restoredState.selectedNodeId);
api.goToNodeId(restoredState.selectedNodeId, "localStorage");
}, [doc.id, isStateRestored.current, state, api]);
useEffect(() => {
+261 -8
View File
@@ -1,4 +1,4 @@
import { useReducer, Reducer, useCallback, Dispatch } from "react";
import { useReducer, Reducer, useCallback, Dispatch, useEffect } from "react";
import { useVirtual, VirtualItem } from "react-virtual";
type UseVirtualOptions<R> = Parameters<typeof useVirtual>[0];
@@ -22,8 +22,11 @@ export type VirtualNode<T> = {
export type UseVirtualTreeInstance<T> = {
nodes: VirtualNode<T>[];
focusedNodeId: string | null;
totalSize: number;
toggleNode: (id: string) => void;
focusNode: (id: string) => void;
focusFirst: () => void;
scrollToNode: (id: string) => void;
getTreeProps: () => React.HTMLAttributes<HTMLElement>;
};
@@ -49,7 +52,42 @@ type ToggleNodeAction = {
id: string;
};
type TreeAction = ToggleNodeAction;
type FocusNodeAction = {
type: "FOCUS_NODE";
id: string;
};
type MoveNodeAction = {
type: "MOVE_DOWN" | "MOVE_UP" | "MOVE_TO_TOP" | "MOVE_TO_BOTTOM";
source: KeyboardEvent | MouseEvent;
};
type MoveRightAction = {
type: "MOVE_RIGHT";
source: KeyboardEvent | MouseEvent;
id: string;
isCollapsed: boolean;
};
type MoveLeftAction = {
type: "MOVE_LEFT";
source: KeyboardEvent | MouseEvent;
id: string;
isCollapsed: boolean;
hasChildren: boolean;
};
type FocusFirstAction = {
type: "FOCUS_FIRST";
};
type TreeAction =
| ToggleNodeAction
| MoveNodeAction
| FocusNodeAction
| FocusFirstAction
| MoveRightAction
| MoveLeftAction;
function expandNode<T extends { id: string; children?: T[] }>(
state: TreeState<T>,
@@ -100,6 +138,126 @@ export function useVirtualTree<T extends { id: string; children?: T[] }, R>(
return collapseNode<T>(state, action.id);
}
}
case "FOCUS_NODE": {
return {
...state,
focusedNodeId: action.id,
};
}
case "FOCUS_FIRST":
case "MOVE_TO_TOP": {
const nextItem = state.items[0];
if (!nextItem) {
return state;
}
return {
...state,
focusedNodeId: nextItem.id,
};
}
case "MOVE_TO_BOTTOM": {
const nextItem = state.items[state.items.length - 1];
if (!nextItem) {
return state;
}
return {
...state,
focusedNodeId: nextItem.id,
};
}
case "MOVE_DOWN": {
const focusedNodeIdIndex = state.items.findIndex(
(item) => item.id === state.focusedNodeId
);
if (focusedNodeIdIndex === -1) {
return state;
}
if (state.items.length <= focusedNodeIdIndex + 1) {
return state;
}
const nextItem = state.items[focusedNodeIdIndex + 1];
return {
...state,
focusedNodeId: nextItem.id,
};
}
case "MOVE_UP": {
const focusedNodeIdIndex = state.items.findIndex(
(item) => item.id === state.focusedNodeId
);
if (focusedNodeIdIndex === -1) {
return state;
}
if (focusedNodeIdIndex === 0) {
return state;
}
const nextItem = state.items[focusedNodeIdIndex - 1];
return {
...state,
focusedNodeId: nextItem.id,
};
}
case "MOVE_RIGHT": {
if (action.isCollapsed) {
return expandNode<T>(state, action.id);
}
const nodeIndex = state.items.findIndex(
(item) => item.id === action.id
);
if (nodeIndex === -1) {
return state;
}
if (state.items.length <= nodeIndex + 1) {
return state;
}
const nextItem = state.items[nodeIndex + 1];
return {
...state,
focusedNodeId: nextItem.id,
};
}
case "MOVE_LEFT": {
if (action.hasChildren && !action.isCollapsed) {
return collapseNode<T>(state, action.id);
}
if (!action.hasChildren || action.isCollapsed) {
// Try to go to the parent node
const parentNodeIndex = state.items.findIndex(
(item) =>
item.node.children &&
item.node.children.map((child) => child.id).includes(action.id)
);
if (parentNodeIndex === -1) {
return state;
}
const nextItem = state.items[parentNodeIndex];
return {
...state,
focusedNodeId: nextItem.id,
};
}
}
default:
return state;
}
@@ -150,9 +308,24 @@ export function useVirtualTree<T extends { id: string; children?: T[] }, R>(
};
});
const toggleNode = useCallback((id: string) => {
dispatch({ type: "TOGGLE_NODE", id });
}, []);
const toggleNode = useCallback(
(id: string) => {
dispatch({ type: "TOGGLE_NODE", id });
},
[dispatch]
);
const focusNode = useCallback(
(id: string) => {
dispatch({ type: "FOCUS_NODE", id });
},
[dispatch]
);
const focusFirst = useCallback(
() => dispatch({ type: "FOCUS_FIRST" }),
[dispatch]
);
// TODO: have this work with collapsed nodes
const scrollToNode = useCallback(
@@ -160,16 +333,26 @@ export function useVirtualTree<T extends { id: string; children?: T[] }, R>(
const itemIndex = state.items.findIndex((item) => item.id === id);
if (itemIndex !== -1) {
rowVirtualizer.scrollToIndex(itemIndex, { align: "center" });
rowVirtualizer.scrollToIndex(itemIndex, { align: "auto" });
}
},
[state.items, rowVirtualizer]
[state.items, rowVirtualizer.scrollToIndex]
);
useEffect(() => {
if (state.focusedNodeId) {
scrollToNode(state.focusedNodeId);
focusNode(state.focusedNodeId);
}
}, [state.focusedNodeId, scrollToNode, focusNode]);
return {
nodes: allVirtualNodes,
totalSize: rowVirtualizer.totalSize,
toggleNode,
focusNode,
focusFirst,
focusedNodeId: state.focusedNodeId,
getTreeProps: useCallback(
() => ({
role: "tree",
@@ -219,6 +402,76 @@ function createItemProps<T extends { id: string; children?: T[] }>(
"aria-posinset": pos,
"aria-setsize": size,
role: "treeitem",
tabIndex: node.id === state.focusedNodeId ? 0 : -1,
tabIndex: node.id === state.focusedNodeId ? -1 : undefined,
onClick: (e) => {
if (e.defaultPrevented) {
return; // Do nothing if the event was already processed
}
if (node.id !== state.focusedNodeId) {
dispatch({ type: "FOCUS_NODE", id: node.id });
}
},
onKeyDown: (e) => {
if (e.defaultPrevented) {
return; // Do nothing if the event was already processed
}
if (node.id === state.focusedNodeId) {
switch (e.key) {
case "Home": {
dispatch({ type: "MOVE_TO_TOP", source: e.nativeEvent });
e.preventDefault();
break;
}
case "End": {
dispatch({ type: "MOVE_TO_BOTTOM", source: e.nativeEvent });
e.preventDefault();
break;
}
case "Down":
case "ArrowDown": {
dispatch({ type: "MOVE_DOWN", source: e.nativeEvent });
e.preventDefault();
break;
}
case "Up":
case "ArrowUp": {
dispatch({ type: "MOVE_UP", source: e.nativeEvent });
e.preventDefault();
break;
}
case "Left":
case "ArrowLeft": {
dispatch({
type: "MOVE_LEFT",
id: node.id,
isCollapsed,
hasChildren:
typeof node.children !== "undefined" &&
node.children.length > 0,
source: e.nativeEvent,
});
e.preventDefault();
break;
}
case "Right":
case "ArrowRight": {
if (node.children && node.children.length > 0) {
dispatch({
type: "MOVE_RIGHT",
id: node.id,
isCollapsed,
source: e.nativeEvent,
});
e.preventDefault();
}
break;
}
}
}
},
});
}
+1 -1
View File
@@ -114,7 +114,7 @@ export default function JsonDocumentRoute() {
<JsonProvider initialJson={loaderData.json}>
<JsonSchemaProvider>
<JsonColumnViewProvider>
<JsonTreeViewProvider overscan={10}>
<JsonTreeViewProvider overscan={25}>
<div>
<div className="h-screen flex flex-col">
<Header />
+21 -7
View File
@@ -39,6 +39,7 @@ export type ColumnViewInstanceState = {
columns: Array<ColumnDefinition>;
getColumnViewProps: () => ColumnViewProps;
selectedNodeId?: string;
selectedNodeSource?: string;
selectedPath: string[];
highlightedNodeId?: string;
highlightedPath: string[];
@@ -54,7 +55,7 @@ export type ColumnViewAPIOptions = {
export type ColumnViewAPI = {
goBack: () => void;
goForward: () => void;
goToNodeId: (nodeId: string) => void;
goToNodeId: (nodeId: string, source: string) => void;
goToParent: (options?: ColumnViewAPIOptions) => void;
goToChildren: () => void;
goToNextSibling: () => void;
@@ -157,8 +158,8 @@ export function useColumnView({
goForward: () => {
dispatch(goForwardAction());
},
goToNodeId: (nodeId: string) => {
dispatch(goToNodeIdAction(nodeId));
goToNodeId: (nodeId: string, source: string) => {
dispatch(goToNodeIdAction(nodeId, source));
},
goToParent: (options?: ColumnViewAPIOptions) => {
dispatch(goToParentAction(options));
@@ -176,10 +177,15 @@ export function useColumnView({
dispatch(resetSelectionAction());
},
};
}, []);
}, [dispatch]);
const { selectedNodeId, highlightedNodeId, history, historyCurrentIndex } =
state;
const {
selectedNodeId,
highlightedNodeId,
selectedNodeSource,
history,
historyCurrentIndex,
} = state;
const selectedPath = getPathToNode(nodeTable, selectedNodeId);
const highlightedPath = getPathToNode(nodeTable, highlightedNodeId);
@@ -202,6 +208,7 @@ export function useColumnView({
return {
state: {
selectedNodeId,
selectedNodeSource,
selectedPath,
selectedNodes,
highlightedNodeId,
@@ -218,6 +225,7 @@ export function useColumnView({
export type ColumnViewState = {
selectedNodeId?: string;
highlightedNodeId?: string;
selectedNodeSource?: string;
history: Array<Omit<ColumnViewState, "history" | "historyCurrentIndex">>;
historyCurrentIndex: number;
nodeTable: NodeTable;
@@ -227,6 +235,7 @@ export type ColumnViewState = {
export type SetSelectedNodeIdAction = {
type: "SET_SELECTED_NODE_ID";
id: string;
source: string;
};
export type MoveSelectedNodeAction = {
@@ -269,10 +278,14 @@ function resetSelectionAction(): ResetSelectionNodeAction {
};
}
function goToNodeIdAction(nodeId: string): SetSelectedNodeIdAction {
function goToNodeIdAction(
nodeId: string,
source: string
): SetSelectedNodeIdAction {
return {
type: "SET_SELECTED_NODE_ID",
id: nodeId,
source,
};
}
@@ -332,6 +345,7 @@ function columnViewReducer(
...state,
selectedNodeId: action.id,
highlightedNodeId: action.id,
selectedNodeSource: action.source,
};
case "MOVE_DOWN": {
if (state.highlightedNodeId === state.rootNodeId) {
+6 -2
View File
@@ -31,15 +31,19 @@ export function formatValue(type: JSONValueType): string | undefined {
case "array": {
if (type.value.length == 0) {
return formatRawValue(type);
} else if (type.value.length === 1) {
return `1 item`;
} else {
return undefined;
return `${type.value.length} items`;
}
}
case "object": {
if (Object.keys(type.value).length == 0) {
return formatRawValue(type);
} else if (Object.keys(type.value).length === 1) {
return `1 field`;
} else {
return undefined;
return `${Object.keys(type.value).length} fields`;
}
}
case "bool": {
-18
View File
@@ -42,8 +42,6 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^2.2.2",
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"@remix-run/dev": "^1.2.3",
"@tailwindcss/forms": "^0.4.0",
"@types/color": "^3.0.3",
@@ -925,15 +923,6 @@
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
"optional": true
},
"node_modules/@esbuild-plugins/node-globals-polyfill": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.1.1.tgz",
"integrity": "sha512-MR0oAA+mlnJWrt1RQVQ+4VYuRJW/P2YmRTv1AsplObyvuBMnPHiizUF95HHYiSsMGLhyGtWufaq2XQg6+iurBg==",
"dev": true,
"peerDependencies": {
"esbuild": "*"
}
},
"node_modules/@esbuild-plugins/node-modules-polyfill": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.1.4.tgz",
@@ -14193,13 +14182,6 @@
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
"optional": true
},
"@esbuild-plugins/node-globals-polyfill": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.1.1.tgz",
"integrity": "sha512-MR0oAA+mlnJWrt1RQVQ+4VYuRJW/P2YmRTv1AsplObyvuBMnPHiizUF95HHYiSsMGLhyGtWufaq2XQg6+iurBg==",
"dev": true,
"requires": {}
},
"@esbuild-plugins/node-modules-polyfill": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.1.4.tgz",
+1 -3
View File
@@ -59,8 +59,6 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^2.2.2",
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"@remix-run/dev": "^1.2.3",
"@tailwindcss/forms": "^0.4.0",
"@types/color": "^3.0.3",
@@ -101,4 +99,4 @@
},
"sideEffects": false,
"main": "dist/worker.js"
}
}
+2
View File
@@ -13,6 +13,8 @@
"paths": {
"~/*": ["./app/*"]
},
"skipDefaultLibCheck": true,
"skipLibCheck": true,
// Remix takes care of building everything in `remix build`.
"noEmit": true