Skip to content

Commit

Permalink
chore(react-tree-grid): initial implementation (#80)
Browse files Browse the repository at this point in the history
  • Loading branch information
bsunderhus authored Jan 22, 2024
1 parent ba9f178 commit 2bdc6e6
Show file tree
Hide file tree
Showing 20 changed files with 401 additions and 1,149 deletions.
12 changes: 7 additions & 5 deletions packages/react-tree-grid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
"version": "0.0.1",
"private": true,
"peerDependencies": {
"@fluentui/react-components": ">=9.44.4 < 10.0.0",
"@fluentui/react-shared-contexts": ">=9.7.2 <10.0.0",
"@fluentui/keyboard-keys": ">=9.0.6 < 10.0.0",
"@fluentui/react-tabster": ">=9.14.0 < 10.0.0",
"@fluentui/react-utilities": ">=9.15.1 < 10.0.0",
"@types/react": ">=16.8.0 <19.0.0",
"@types/react-dom": ">=16.8.0 <19.0.0",
"react": ">=16.8.0 <19.0.0",
"react-dom": ">=16.8.0 <19.0.0"
},
"dependencies": {
"@fluentui/react-components": ">=9.44.4 < 10.0.0",
"@fluentui/react-context-selector": ">=9.1.47 < 10.0.0",
"@fluentui/keyboard-keys": ">=9.0.6 < 10.0.0",
"@fluentui/react-tabster": ">=9.14.0 < 10.0.0",
"@fluentui/react-utilities": ">=9.15.1 < 10.0.0"
}
}
16 changes: 7 additions & 9 deletions packages/react-tree-grid/src/components/TreeGrid/TreeGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import * as React from 'react';
import { makeResetStyles, mergeClasses } from '@fluentui/react-components';

export type TreeGridProps = JSX.IntrinsicElements['div'];

const useStyles = makeResetStyles({
display: 'block',
});
import { mergeClasses } from '@fluentui/react-components';
import { useTreeGridStyles } from './useTreeGridStyles.styles';
import { TreeGridProps } from './TreeGrid.types';
import { useNavigation } from '../../hooks/useNavigation';

export const TreeGrid = React.forwardRef(
(props: TreeGridProps, ref: React.ForwardedRef<HTMLDivElement>) => {
const styles = useStyles();
return (
<div
ref={ref}
role="treegrid"
{...props}
className={mergeClasses(styles, props.className)}
className={mergeClasses(useTreeGridStyles(), props.className)}
{...useNavigation(props)}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type TreeGridProps = JSX.IntrinsicElements['div'];
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { makeResetStyles, mergeClasses } from '@fluentui/react-components';

const useResetStyles = makeResetStyles({
display: 'block',
});

export const useTreeGridStyles = () =>
mergeClasses('fui-TreeGrid', useResetStyles());
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
import * as React from 'react';
import {
mergeClasses,
useTableCell_unstable,
TableCellState,
useTableCellStyles_unstable,
} from '@fluentui/react-components';
import { mergeClasses } from '@fluentui/react-components';
import { useTreeGridCellStyles } from './useTreeGridCellStyles.styles';

export type TreeGridCellProps = JSX.IntrinsicElements['div'];
export type TreeGridCellProps = Omit<JSX.IntrinsicElements['div'], 'header'> & {
header?: boolean;
};

export const TreeGridCell = React.forwardRef(
(props: TreeGridCellProps, ref: React.ForwardedRef<HTMLDivElement>) => {
const tableCellState: TableCellState = {
...useTableCell_unstable({ as: 'div' }, ref),
noNativeElements: true,
};
useTableCellStyles_unstable(tableCellState);
const styles = useTreeGridCellStyles();
const { header, className, ...rest } = props;
return (
<div
ref={ref}
{...props}
className={mergeClasses(tableCellState.root.className, props.className)}
role={header ? 'rowheader' : 'gridcell'}
{...rest}
className={mergeClasses(styles, className)}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { makeResetStyles, mergeClasses } from '@fluentui/react-components';

const useResetStyles = makeResetStyles({
flex: '1 1 auto',
});

export const useTreeGridCellStyles = () =>
mergeClasses('fui-TreeGridCell', useResetStyles());
31 changes: 20 additions & 11 deletions packages/react-tree-grid/src/components/TreeGridRow/TreeGridRow.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
import * as React from 'react';
import {
mergeClasses,
useTableRowStyles_unstable,
useTableRow_unstable,
TableRowState,
useArrowNavigationGroup,
useFocusableGroup,
} from '@fluentui/react-components';

export type TreeGridRowProps = JSX.IntrinsicElements['div'];
import { useTreeGridRowStyles } from './useTreeGridRowStyles.styles';
import { TreeGridRowProps } from './TreeGridRow.types';
import { useMergedTabsterAttributes_unstable } from '@fluentui/react-tabster';

export const TreeGridRow = React.forwardRef(
(props: TreeGridRowProps, ref: React.ForwardedRef<HTMLDivElement>) => {
const tableRowState: TableRowState = {
...useTableRow_unstable({ as: 'div' }, ref),
noNativeElements: true,
};
useTableRowStyles_unstable(tableRowState);
const styles = useTreeGridRowStyles();
const tabsterAttributes = useMergedTabsterAttributes_unstable(
useArrowNavigationGroup({
axis: 'horizontal',
memorizeCurrent: true,
}),
useFocusableGroup({
tabBehavior: 'limited-trap-focus',
ignoreDefaultKeydown: { Enter: true },
})
);
return (
<div
ref={ref}
role="row"
tabIndex={0}
{...props}
className={mergeClasses(tableRowState.root.className, props.className)}
className={mergeClasses(styles, props.className)}
{...tabsterAttributes}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type TreeGridRowProps = JSX.IntrinsicElements['div'] & {
// aria-level is required for screen readers to understand the nesting level of the row
'aria-level': number | string;
};
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './TreeGridRow';
export type * from './TreeGridRow.types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {
makeResetStyles,
mergeClasses,
createFocusOutlineStyle,
} from '@fluentui/react-components';

const useResetStyles = makeResetStyles({
display: 'flex',
alignItems: 'center',
position: 'relative',
...createFocusOutlineStyle(),
});

export const useTreeGridRowStyles = () => {
return mergeClasses('fui-TreeGridRow', useResetStyles());
};
99 changes: 99 additions & 0 deletions packages/react-tree-grid/src/hooks/useNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import * as React from 'react';
import {
useArrowNavigationGroup,
useFocusFinders,
} from '@fluentui/react-tabster';
import type { TreeGridProps } from '../components/TreeGrid/TreeGrid.types';
import { isHTMLElement, useEventCallback } from '@fluentui/react-utilities';
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
Escape,
keyCodes,
} from '@fluentui/keyboard-keys';

export const useNavigation = (props: Pick<TreeGridProps, 'onKeyDown'>) => {
const tabsterAttributes = useArrowNavigationGroup({
axis: 'vertical',
memorizeCurrent: true,
});
const { findFirstFocusable } = useFocusFinders();
const findParentRow = useFindParentRow();
const handleKeyDown = useEventCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
props.onKeyDown?.(event);
// TreeGridRow
if (isHTMLElement(event.target) && event.target.role === 'row') {
switch (event.key) {
case ArrowRight: {
const ariaExpanded = event.target.getAttribute('aria-expanded');
if (ariaExpanded === 'false') {
return;
}
findFirstFocusable(event.target)?.focus();
return;
}
case ArrowLeft: {
const ariaExpanded = event.target.getAttribute('aria-expanded');
if (ariaExpanded === 'true') {
return;
}
findParentRow(event.target)?.focus();
return;
}
}
return;
}
// TreeGridCell
switch (event.key) {
case ArrowDown:
case ArrowUp:
case ArrowLeft: {
event.target.dispatchEvent(
new KeyboardEvent('keydown', {
key: Escape,
keyCode: keyCodes.Escape,
})
);
}
}
}
);
return { onKeyDown: handleKeyDown, ...tabsterAttributes };
};

const useFindParentRow = () => {
const { findPrevFocusable } = useFocusFinders();
return React.useCallback(
(currentRow: HTMLElement): HTMLElement | null => {
const currentLevel = Number(currentRow.getAttribute('aria-level'));
if (isNaN(currentLevel)) {
if (process.env.NODE_ENV !== 'production') {
console.error(
`TreeGrid: aria-level ${currentLevel} is not a number, at row:`,
currentRow
);
}
return null;
}
if (currentLevel === 1) {
return null;
}
let element = currentRow;
while (Number(element.getAttribute('aria-level')) !== currentLevel - 1) {
const nextElement = findPrevFocusable(element);
if (!nextElement) {
return null;
}
element = nextElement;
}
if (element !== currentRow) {
return element;
}
return null;
},
[findPrevFocusable]
);
};
85 changes: 85 additions & 0 deletions packages/react-tree-grid/src/hooks/useTreeGridControl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { ArrowLeft, ArrowRight, Enter } from '@fluentui/keyboard-keys';
import { isHTMLElement, useEventCallback } from '@fluentui/react-utilities';
import * as React from 'react';

export type TreeGridRowId = string;

export const useTreeGridOpenRows = () => {
const [openRows, setOpenRows] = React.useState(
() => new Set<TreeGridRowId>()
);
const openRow = React.useCallback((rowId: TreeGridRowId) => {
setOpenRows((previousSet) => new Set(previousSet).add(rowId));
}, []);
const closeRow = React.useCallback((rowId: TreeGridRowId) => {
setOpenRows((previousSet) => {
const newSet = new Set(previousSet);
newSet.delete(rowId);
return newSet;
});
}, []);
const handleKeyDown = useEventCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (isHTMLElement(event.target) && event.target.role === 'row') {
switch (event.key) {
case Enter: {
return event.target.click();
}
case ArrowRight: {
if (
event.target.getAttribute('aria-expanded') === 'false' &&
event.target.dataset.treeGridRowId
) {
openRow(event.target.dataset.treeGridRowId);
}
return;
}
case ArrowLeft: {
if (
event.target.getAttribute('aria-expanded') === 'true' &&
event.target.dataset.treeGridRowId
) {
closeRow(event.target.dataset.treeGridRowId);
}
return;
}
}
}
}
);
const handleClick = useEventCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!isHTMLElement(event.target)) {
return;
}
let target: HTMLElement;
if (event.target.role === 'row') {
target = event.target;
} else if (event.target.parentElement?.role === 'row') {
target = event.target.parentElement;
} else {
return;
}
if (target.dataset.treeGridRowId) {
const ariaExpanded = target.getAttribute('aria-expanded');
if (ariaExpanded === 'false') {
openRow(target.dataset.treeGridRowId);
} else if (ariaExpanded === 'true') {
closeRow(target.dataset.treeGridRowId);
}
}
}
);
return {
openRows,
setOpenRows,
getTreeGridProps: () => ({
onKeyDown: handleKeyDown,
onClick: handleClick,
}),
getTreeGridRowProps: (props: { id?: TreeGridRowId }) => ({
'data-tree-grid-row-id': props.id,
'aria-expanded': props.id ? openRows.has(props.id) : undefined,
}),
};
};
1 change: 1 addition & 0 deletions packages/react-tree-grid/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './components/TreeGridCell';
export * from './components/TreeGridRow';
export * from './components/TreeGrid';
export { useTreeGridOpenRows } from './hooks/useTreeGridControl';
Loading

0 comments on commit 2bdc6e6

Please sign in to comment.