Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace FileTree with accessible third party treeview component #326

Open
AriPerkkio opened this issue Sep 11, 2024 · 0 comments
Open

Replace FileTree with accessible third party treeview component #326

AriPerkkio opened this issue Sep 11, 2024 · 0 comments
Labels
enhancement New feature or request help wanted Extra attention is needed

Comments

@AriPerkkio
Copy link
Member

AriPerkkio commented Sep 11, 2024

Is your feature request related to a problem?

Currently the FileTree component is a custom component that renders just buttons. There are no lists, list items or appropriate element roles used. Files and folders are just buttons after buttons. Keyboard usage is not implemented. Element states are not properly indicated.

import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { classNames } from '../utils/classnames.js';
const NODE_PADDING_LEFT = 12;
const DEFAULT_HIDDEN_FILES = [/\/node_modules\//];
interface Props {
files: string[];
selectedFile?: string;
onFileSelect?: (filePath: string) => void;
hideRoot: boolean;
scope?: string;
hiddenFiles?: Array<string | RegExp>;
className?: string;
}
export function FileTree({ files, onFileSelect, selectedFile, hideRoot, scope, hiddenFiles, className }: Props) {
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
const fileList = useMemo(
() => buildFileList(files, hideRoot, scope, computedHiddenFiles),
[files, hideRoot, scope, computedHiddenFiles],
);
const [collapsedFolders, setCollapsedFolders] = useState(() => new Set<number>());
// reset collapsed folders when the list of files changes
useEffect(() => {
setCollapsedFolders(new Set<number>());
}, [files]);
const filteredFileList = useMemo(() => {
const list = [];
let lastDepth = Number.MAX_SAFE_INTEGER;
for (const fileOrFolder of fileList) {
const depth = fileOrFolder.depth;
// if the depth is equal we reached the end of the collaped group
if (lastDepth === depth) {
lastDepth = Number.MAX_SAFE_INTEGER;
}
// ignore collapsed folders
if (collapsedFolders.has(fileOrFolder.id)) {
lastDepth = Math.min(lastDepth, depth);
}
// ignore files and folders below the last collapsed folder
if (lastDepth < depth) {
continue;
}
list.push(fileOrFolder);
}
return list;
}, [fileList, collapsedFolders]);
function toggleCollapseState(id: number) {
setCollapsedFolders((prevSet) => {
const newSet = new Set(prevSet);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}
return (
<div className={classNames(className, 'transition-theme bg-tk-elements-fileTree-backgroundColor')}>
{filteredFileList.map((fileOrFolder) => {
switch (fileOrFolder.kind) {
case 'file': {
return (
<File
key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
onClick={() => onFileSelect?.(fileOrFolder.fullPath)}
/>
);
}
case 'folder': {
return (
<Folder
key={fileOrFolder.id}
folder={fileOrFolder}
collapsed={collapsedFolders.has(fileOrFolder.id)}
onClick={() => toggleCollapseState(fileOrFolder.id)}
/>
);
}
}
})}
</div>
);
}
export default FileTree;
interface FolderProps {
folder: FolderNode;
collapsed: boolean;
onClick: () => void;
}
function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
return (
<NodeButton
className="group transition-theme bg-tk-elements-fileTree-folder-backgroundColor hover:bg-tk-elements-fileTree-folder-backgroundColorHover text-tk-elements-fileTree-folder-textColor hover:text-tk-elements-fileTree-folder-textColor"
depth={depth}
iconClasses={classNames(
'text-tk-elements-fileTree-folder-iconColor group-hover:text-tk-elements-fileTree-folder-iconColorHover',
{
'i-ph-folder-simple-duotone': collapsed,
'i-ph-folder-open-duotone': !collapsed,
},
)}
onClick={onClick}
>
{name}
</NodeButton>
);
}
interface FileProps {
file: FileNode;
selected: boolean;
onClick: () => void;
}
function File({ file: { depth, name }, onClick, selected }: FileProps) {
return (
<NodeButton
className={classNames('group transition-theme', {
'bg-tk-elements-fileTree-file-backgroundColor hover:bg-tk-elements-fileTree-file-backgroundColorHover text-tk-elements-fileTree-file-textColor hover:text-tk-elements-fileTree-file-textColorHover':
!selected,
'bg-tk-elements-fileTree-file-backgroundColorSelected text-tk-elements-fileTree-file-textColorSelected':
selected,
})}
depth={depth}
iconClasses={classNames('i-ph-file-duotone', {
'text-tk-elements-fileTree-file-iconColor group-hover:text-tk-elements-fileTree-file-iconColorHover': !selected,
'text-tk-elements-fileTree-file-iconColorSelected': selected,
})}
onClick={onClick}
aria-pressed={selected}
>
{name}
</NodeButton>
);
}
interface ButtonProps {
depth: number;
iconClasses: string;
children: ReactNode;
className?: string;
onClick?: () => void;
'aria-pressed'?: boolean;
}
function NodeButton({ depth, iconClasses, onClick, className, 'aria-pressed': ariaPressed, children }: ButtonProps) {
return (
<button
className={`flex items-center gap-2 w-full pr-2 border-2 border-transparent text-faded ${className ?? ''}`}
style={{ paddingLeft: `${12 + depth * NODE_PADDING_LEFT}px` }}
onClick={() => onClick?.()}
aria-pressed={ariaPressed === true ? 'true' : undefined}
>
<div className={classNames('scale-120 shrink-0', iconClasses)}></div>
<span>{children}</span>
</button>
);
}
type Node = FileNode | FolderNode;
interface BaseNode {
id: number;
depth: number;
name: string;
}
interface FileNode extends BaseNode {
kind: 'file';
fullPath: string;
}
interface FolderNode extends BaseNode {
kind: 'folder';
}
function buildFileList(
files: string[],
hideRoot: boolean,
scope: string | undefined,
hiddenFiles: Array<string | RegExp>,
): Node[] {
const folderPaths = new Set<string>();
const fileList: Node[] = [];
const defaultDepth = hideRoot ? 0 : 1;
if (!hideRoot) {
fileList.push({ kind: 'folder', name: '/', depth: 0, id: 0 });
}
for (const filePath of files) {
if (scope && !filePath.startsWith(scope)) {
continue;
}
const segments = filePath.split('/').filter((s) => s);
const fileName = segments.at(-1);
if (!fileName || isHiddenFile(filePath, fileName, hiddenFiles)) {
continue;
}
let currentPath = '';
for (let depth = 0; depth < segments.length; ++depth) {
const name = segments[depth];
const fullPath = (currentPath += `/${name}`);
if (depth === segments.length - 1) {
fileList.push({
kind: 'file',
id: fileList.length,
name,
fullPath,
depth: depth + defaultDepth,
});
} else if (!folderPaths.has(fullPath)) {
folderPaths.add(fullPath);
fileList.push({
kind: 'folder',
id: fileList.length,
name,
depth: depth + defaultDepth,
});
}
}
}
return fileList;
}
function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<string | RegExp>) {
return hiddenFiles.some((pathOrRegex) => {
if (typeof pathOrRegex === 'string') {
return fileName === pathOrRegex;
}
return pathOrRegex.test(filePath);
});
}

Describe the solution you'd like.

File tree should implement proper HTML semantics and expected interactivity. It should follow https://www.w3.org/WAI/ARIA/apg/patterns/treeview/ pattern.

Implementing such component is difficult and slow. It's not something that TutorialKit itself should focus on.

Check Radix, React Aria and similar component libraries for ready-to-use treeview components and build TutorialKit's FileTree on top of that.

Describe alternatives you've considered.

Ignore accessibility and usability completely and wait for users to file bug reports. Keep building more and more work-arounds on top of the current component. 🫠

Additional context

Note

Help wanted!

Check existing component libraries like Radix and React Aria, and see if they have treeview component that could be used.

@AriPerkkio AriPerkkio added enhancement New feature or request help wanted Extra attention is needed labels Sep 11, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request help wanted Extra attention is needed
Projects
None yet
Development

No branches or pull requests

1 participant