diff --git a/packages/react-tree-grid/.eslintrc.json b/packages/react-tree-grid/.eslintrc.json
new file mode 100644
index 00000000..5f831ca3
--- /dev/null
+++ b/packages/react-tree-grid/.eslintrc.json
@@ -0,0 +1,24 @@
+{
+ "extends": ["../../.eslintrc.json"],
+ "ignorePatterns": ["!**/*"],
+ "overrides": [
+ {
+ "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.ts", "*.tsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.js", "*.jsx"],
+ "rules": {}
+ },
+ {
+ "files": ["stories/**/*.tsx", "stories/**/*.ts"],
+ "rules": {
+ "@nx/enforce-module-boundaries": "off"
+ }
+ }
+ ]
+}
diff --git a/packages/react-tree-grid/.storybook/main.ts b/packages/react-tree-grid/.storybook/main.ts
new file mode 100644
index 00000000..7495cf3f
--- /dev/null
+++ b/packages/react-tree-grid/.storybook/main.ts
@@ -0,0 +1,30 @@
+import type { StorybookConfig } from '@storybook/react-webpack5';
+
+const config: StorybookConfig = {
+ stories: ['../stories/**/index.stories.@(js|jsx|ts|tsx|mdx)'],
+ addons: [
+ '@nx/react/plugins/storybook',
+ {
+ name: '@storybook/addon-storysource',
+ options: {
+ loaderOptions: {
+ injectStoryParameters: true,
+ },
+ },
+ },
+ ],
+ framework: {
+ name: '@storybook/react-webpack5',
+ options: {},
+ },
+ core: {
+ builder: '@storybook/builder-webpack5',
+ disableTelemetry: true,
+ },
+};
+
+export default config;
+
+// To customize your webpack configuration you can use the webpackFinal field.
+// Check https://storybook.js.org/docs/react/builders/webpack#extending-storybooks-webpack-config
+// and https://nx.dev/packages/storybook/documents/custom-builder-configs
diff --git a/packages/react-tree-grid/.storybook/preview.tsx b/packages/react-tree-grid/.storybook/preview.tsx
new file mode 100644
index 00000000..5f63e651
--- /dev/null
+++ b/packages/react-tree-grid/.storybook/preview.tsx
@@ -0,0 +1,17 @@
+import * as React from 'react';
+
+import { Preview } from '@storybook/react';
+
+import { FluentProvider, webLightTheme } from '@fluentui/react-components';
+
+const preview: Preview = {
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default preview;
diff --git a/packages/react-tree-grid/.storybook/tsconfig.json b/packages/react-tree-grid/.storybook/tsconfig.json
new file mode 100644
index 00000000..411883c0
--- /dev/null
+++ b/packages/react-tree-grid/.storybook/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "emitDecoratorMetadata": true,
+ "outDir": ""
+ },
+ "files": [
+ "../../../node_modules/@nx/react/typings/styled-jsx.d.ts",
+ "../../../node_modules/@nx/react/typings/cssmodule.d.ts",
+ "../../../node_modules/@nx/react/typings/image.d.ts"
+ ],
+ "exclude": [
+ "../**/*.spec.ts",
+ "../**/*.spec.js",
+ "../**/*.spec.tsx",
+ "../**/*.spec.jsx"
+ ],
+ "include": [
+ "../stories/**/*.stories.ts",
+ "../stories/**/*.stories.js",
+ "../stories/**/*.stories.jsx",
+ "../stories/**/*.stories.tsx",
+ "../stories/**/*.stories.mdx",
+ "*.ts",
+ "*.js",
+ "../stories/VariantB.stories.tsx"
+ ]
+}
diff --git a/packages/react-tree-grid/.swcrc b/packages/react-tree-grid/.swcrc
new file mode 100644
index 00000000..0e2acf03
--- /dev/null
+++ b/packages/react-tree-grid/.swcrc
@@ -0,0 +1,31 @@
+{
+ "jsc": {
+ "target": "es2019",
+ "parser": {
+ "syntax": "typescript",
+ "tsx": true,
+ "decorators": false,
+ "dynamicImport": false
+ },
+ "transform": {
+ "react": {
+ "runtime": "automatic",
+ "importSource": "@fluentui/react-jsx-runtime",
+ "useSpread": true
+ }
+ },
+ "keepClassNames": true,
+ "externalHelpers": true,
+ "loose": true
+ },
+ "sourceMaps": true,
+ "exclude": [
+ "jest.config.ts",
+ ".*\\.spec.tsx?$",
+ ".*\\.test.tsx?$",
+ "./src/jest-setup.ts$",
+ "./**/jest-setup.ts$",
+ ".*.js$"
+ ],
+ "$schema": "https://json.schemastore.org/swcrc"
+}
diff --git a/packages/react-tree-grid/README.md b/packages/react-tree-grid/README.md
new file mode 100644
index 00000000..c2afb016
--- /dev/null
+++ b/packages/react-tree-grid/README.md
@@ -0,0 +1,11 @@
+# react-tree-grid
+
+This library was generated with [Nx](https://nx.dev).
+
+## Building
+
+Run `nx build react-tree-grid` to build the library.
+
+## Running unit tests
+
+Run `nx test react-tree-grid` to execute the unit tests via [Jest](https://jestjs.io).
diff --git a/packages/react-tree-grid/jest.config.ts b/packages/react-tree-grid/jest.config.ts
new file mode 100644
index 00000000..80b8addc
--- /dev/null
+++ b/packages/react-tree-grid/jest.config.ts
@@ -0,0 +1,29 @@
+/* eslint-disable */
+import { readFileSync } from 'fs';
+
+// Reading the SWC compilation config and remove the "exclude"
+// for the test files to be compiled by SWC
+const { exclude: _, ...swcJestConfig } = JSON.parse(
+ readFileSync(`${__dirname}/.swcrc`, 'utf-8')
+);
+
+// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves.
+// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude"
+if (swcJestConfig.swcrc === undefined) {
+ swcJestConfig.swcrc = false;
+}
+
+// Uncomment if using global setup/teardown files being transformed via swc
+// https://nx.dev/packages/jest/documents/overview#global-setup/teardown-with-nx-libraries
+// jest needs EsModule Interop to find the default exported setup/teardown functions
+// swcJestConfig.module.noInterop = false;
+
+export default {
+ displayName: 'button',
+ preset: '../../jest.preset.js',
+ transform: {
+ '^.+\\.[tj]sx?$': ['@swc/jest', swcJestConfig],
+ },
+ moduleFileExtensions: ['js', 'ts', 'tsx', 'html'],
+ coverageDirectory: '../../coverage/packages/button',
+};
diff --git a/packages/react-tree-grid/package.json b/packages/react-tree-grid/package.json
new file mode 100644
index 00000000..4305b1fe
--- /dev/null
+++ b/packages/react-tree-grid/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@fluentui-contrib/react-tree-grid",
+ "version": "0.0.1",
+ "private": true,
+ "peerDependencies": {
+ "@fluentui/react-components": ">=9.35.1 < 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"
+ }
+}
diff --git a/packages/react-tree-grid/project.json b/packages/react-tree-grid/project.json
new file mode 100644
index 00000000..944d5812
--- /dev/null
+++ b/packages/react-tree-grid/project.json
@@ -0,0 +1,68 @@
+{
+ "name": "react-tree-grid",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "packages/react-tree-grid/src",
+ "projectType": "library",
+ "targets": {
+ "build": {
+ "executor": "@fluentui-contrib/nx-plugin:build"
+ },
+ "publish": {
+ "command": "node tools/scripts/publish.mjs react-tree-grid {args.ver} {args.tag}",
+ "dependsOn": ["build"]
+ },
+ "lint": {
+ "executor": "@nx/linter:eslint",
+ "outputs": ["{options.outputFile}"],
+ "options": {
+ "lintFilePatterns": [
+ "packages/react-tree-grid/**/*.ts",
+ "packages/react-tree-grid/**/*.tsx"
+ ]
+ }
+ },
+ "test": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+ "options": {
+ "jestConfig": "packages/react-tree-grid/jest.config.ts",
+ "passWithNoTests": true
+ },
+ "configurations": {
+ "ci": {
+ "ci": true,
+ "codeCoverage": true
+ }
+ }
+ },
+ "type-check": {
+ "executor": "@fluentui-contrib/nx-plugin:type-check"
+ },
+ "storybook": {
+ "executor": "@nx/storybook:storybook",
+ "options": {
+ "port": 4400,
+ "configDir": "packages/react-tree-grid/.storybook"
+ },
+ "configurations": {
+ "ci": {
+ "quiet": true
+ }
+ }
+ },
+ "build-storybook": {
+ "executor": "@nx/storybook:build",
+ "outputs": ["{options.outputDir}"],
+ "options": {
+ "outputDir": "dist/storybook/react-tree-grid",
+ "configDir": "packages/react-tree-grid/.storybook"
+ },
+ "configurations": {
+ "ci": {
+ "quiet": true
+ }
+ }
+ }
+ },
+ "tags": []
+}
diff --git a/packages/react-tree-grid/src/components/TreeGrid/TreeGrid.tsx b/packages/react-tree-grid/src/components/TreeGrid/TreeGrid.tsx
new file mode 100644
index 00000000..f85c1c08
--- /dev/null
+++ b/packages/react-tree-grid/src/components/TreeGrid/TreeGrid.tsx
@@ -0,0 +1,21 @@
+import * as React from 'react';
+import { makeResetStyles, mergeClasses } from '@fluentui/react-components';
+
+export type TreeGridProps = JSX.IntrinsicElements['div'];
+
+const useStyles = makeResetStyles({
+ display: 'block',
+});
+
+export const TreeGrid = React.forwardRef(
+ (props: TreeGridProps, ref: React.ForwardedRef) => {
+ const styles = useStyles();
+ return (
+
+ );
+ }
+);
diff --git a/packages/react-tree-grid/src/components/TreeGrid/index.ts b/packages/react-tree-grid/src/components/TreeGrid/index.ts
new file mode 100644
index 00000000..c55427ac
--- /dev/null
+++ b/packages/react-tree-grid/src/components/TreeGrid/index.ts
@@ -0,0 +1 @@
+export * from './TreeGrid';
diff --git a/packages/react-tree-grid/src/components/TreeGridCell/TreeGridCell.tsx b/packages/react-tree-grid/src/components/TreeGridCell/TreeGridCell.tsx
new file mode 100644
index 00000000..9452a27e
--- /dev/null
+++ b/packages/react-tree-grid/src/components/TreeGridCell/TreeGridCell.tsx
@@ -0,0 +1,26 @@
+import * as React from 'react';
+import {
+ mergeClasses,
+ useTableCell_unstable,
+ TableCellState,
+ useTableCellStyles_unstable,
+} from '@fluentui/react-components';
+
+export type TreeGridCellProps = JSX.IntrinsicElements['div'];
+
+export const TreeGridCell = React.forwardRef(
+ (props: TreeGridCellProps, ref: React.ForwardedRef) => {
+ const tableCellState: TableCellState = {
+ ...useTableCell_unstable({ as: 'div' }, ref),
+ noNativeElements: true,
+ };
+ useTableCellStyles_unstable(tableCellState);
+ return (
+
+ );
+ }
+);
diff --git a/packages/react-tree-grid/src/components/TreeGridCell/index.ts b/packages/react-tree-grid/src/components/TreeGridCell/index.ts
new file mode 100644
index 00000000..d008de0f
--- /dev/null
+++ b/packages/react-tree-grid/src/components/TreeGridCell/index.ts
@@ -0,0 +1 @@
+export * from './TreeGridCell';
diff --git a/packages/react-tree-grid/src/components/TreeGridRow/TreeGridRow.tsx b/packages/react-tree-grid/src/components/TreeGridRow/TreeGridRow.tsx
new file mode 100644
index 00000000..325466b3
--- /dev/null
+++ b/packages/react-tree-grid/src/components/TreeGridRow/TreeGridRow.tsx
@@ -0,0 +1,26 @@
+import * as React from 'react';
+import {
+ mergeClasses,
+ useTableRowStyles_unstable,
+ useTableRow_unstable,
+ TableRowState,
+} from '@fluentui/react-components';
+
+export type TreeGridRowProps = JSX.IntrinsicElements['div'];
+
+export const TreeGridRow = React.forwardRef(
+ (props: TreeGridRowProps, ref: React.ForwardedRef) => {
+ const tableRowState: TableRowState = {
+ ...useTableRow_unstable({ as: 'div' }, ref),
+ noNativeElements: true,
+ };
+ useTableRowStyles_unstable(tableRowState);
+ return (
+
+ );
+ }
+);
diff --git a/packages/react-tree-grid/src/components/TreeGridRow/index.ts b/packages/react-tree-grid/src/components/TreeGridRow/index.ts
new file mode 100644
index 00000000..04983346
--- /dev/null
+++ b/packages/react-tree-grid/src/components/TreeGridRow/index.ts
@@ -0,0 +1 @@
+export * from './TreeGridRow';
diff --git a/packages/react-tree-grid/src/index.ts b/packages/react-tree-grid/src/index.ts
new file mode 100644
index 00000000..25c4cc1e
--- /dev/null
+++ b/packages/react-tree-grid/src/index.ts
@@ -0,0 +1,3 @@
+export * from './components/TreeGridCell';
+export * from './components/TreeGridRow';
+export * from './components/TreeGrid';
diff --git a/packages/react-tree-grid/stories/TreeGridVariantB.stories.tsx b/packages/react-tree-grid/stories/TreeGridVariantB.stories.tsx
new file mode 100644
index 00000000..a3d41ba0
--- /dev/null
+++ b/packages/react-tree-grid/stories/TreeGridVariantB.stories.tsx
@@ -0,0 +1,242 @@
+import * as React from 'react';
+import {
+ MeetingProperty,
+ RecentCategory,
+ RecentMeetings,
+ TreeGridWithEnterInputsRenderer,
+} from './VariantB';
+
+export const categoriesTitles: Record = {
+ today: 'Today',
+ yesterday: 'Yesterday',
+ lastWeek: 'Last week',
+};
+
+const dateLocale = 'en-US';
+const nowDate = new Date('2023-10-01 12:30');
+
+type Meeting = {
+ title: string;
+ startDate: string;
+ endDate: string;
+ properties?: MeetingProperty[];
+ tasksCount?: number;
+};
+
+const meetings: Meeting[] = [
+ // Upcoming meetings
+ {
+ title: 'Weekly summary #3',
+ startDate: '2023-10-06 14:30',
+ endDate: '2023-10-06 15:30',
+ },
+ {
+ title: 'Mandatory training #2',
+ startDate: '2023-10-03 14:30',
+ endDate: '2023-10-03 15:30',
+ },
+ {
+ title: 'Meeting with manager',
+ startDate: '2023-10-03 8:00',
+ endDate: '2023-10-03 9:00',
+ },
+
+ // Recent meetings
+ {
+ title: 'Monthly townhall',
+ startDate: '2023-10-01 10:00',
+ endDate: '2023-10-01 11:00',
+ properties: ['includingContent', 'recorded', 'mentionsOfYou'],
+ },
+ {
+ title: 'Planning for next quarter',
+ startDate: '2023-10-01 11:00',
+ endDate: '2023-10-01 12:00',
+ properties: ['recorded'],
+ },
+ {
+ title: 'Weekly summary #2',
+ startDate: '2023-09-29 14:30',
+ endDate: '2023-09-29 15:30',
+ properties: ['includingContent', 'recorded'],
+ tasksCount: 4,
+ },
+ {
+ title: 'Mandatory training #1',
+ startDate: '2023-09-29 9:00',
+ endDate: '2023-09-29 10:00',
+ properties: ['includingContent', 'recorded', 'mentionsOfYou'],
+ },
+ {
+ title: 'Meeting with John',
+ startDate: '2023-09-28 10:15',
+ endDate: '2023-09-28 11:15',
+ properties: ['transcript', 'includingContent', 'missed'],
+ tasksCount: 2,
+ },
+ {
+ title: 'Weekly summary #1',
+ startDate: '2023-09-22 14:30',
+ endDate: '2023-09-22 15:30',
+ properties: ['includingContent', 'missed', 'recorded', 'mentionsOfYou'],
+ },
+ {
+ title: 'Meeting with Kate',
+ startDate: '2023-09-22 13:30',
+ endDate: '2023-09-22 14:15',
+ properties: ['includingContent', 'transcript'],
+ },
+ {
+ title: 'All hands meeting #1',
+ startDate: '2023-09-19 15:00',
+ endDate: '2023-09-19 16:00',
+ properties: ['recorded', 'missed'],
+ },
+ {
+ title: 'Presentation about TreeGrid',
+ startDate: '2023-09-19 14:00',
+ endDate: '2023-09-19 15:00',
+ properties: ['includingContent', 'recorded', 'transcript'],
+ },
+ {
+ title: 'Meeting with George',
+ startDate: '2023-09-19 10:00',
+ endDate: '2023-09-19 10:30',
+ properties: ['includingContent'],
+ tasksCount: 1,
+ },
+];
+
+const getFormattedTime = (date: Date) => {
+ let hours = date.getHours();
+ let minutes = date.getMinutes().toString();
+ const ampm = hours >= 12 ? 'PM' : 'AM';
+ hours = hours % 12;
+ hours = hours === 0 ? 12 : hours; // The hour 0 should be 12
+ minutes = minutes.padStart(2, '0');
+ const time = `${hours}:${minutes} ${ampm}`;
+ return time;
+};
+
+export const VariantB = () => {
+ const recentCategoriesRef = React.useRef([]);
+
+ const recentMeetings: RecentMeetings = React.useMemo(() => {
+ const result: RecentMeetings = {
+ 'category-today': [],
+ 'category-yesterday': [],
+ 'category-lastWeek': [],
+ };
+ meetings.forEach((meeting, index) => {
+ const meetingStartDate = new Date(meeting.startDate);
+ const meetingEndDate = new Date(meeting.endDate);
+ const meetingEndDateStr = meetingEndDate.toISOString().split('T')[0];
+ const todayStartDateStr = nowDate.toISOString().split('T')[0];
+ const isTodayUntilNow =
+ meetingEndDate < nowDate && meetingEndDateStr === todayStartDateStr;
+ const yesterdayStartDate = new Date(todayStartDateStr);
+ yesterdayStartDate.setDate(yesterdayStartDate.getDate() - 1);
+ const beforeWeekStartDate = new Date(nowDate);
+ beforeWeekStartDate.setDate(nowDate.getDate() - 7);
+ const startTime = getFormattedTime(meetingStartDate);
+ const endTime = getFormattedTime(meetingEndDate);
+
+ // Create the recent meeting
+ const recentMeeting = {
+ ...meeting,
+ id: `recentMeeting${index}`,
+ titleWithTime: `${meeting.title}, ${startTime} to ${endTime}`,
+ revealed: true,
+ };
+
+ // Categorize the recent meeting
+ if (isTodayUntilNow) {
+ result['category-today'].push(recentMeeting);
+ } else if (
+ meetingEndDate < nowDate &&
+ meetingEndDate >= yesterdayStartDate
+ ) {
+ result['category-yesterday'].push(recentMeeting);
+ } else if (
+ meetingEndDate < nowDate &&
+ meetingEndDate >= beforeWeekStartDate
+ ) {
+ result['category-lastWeek'].push(recentMeeting);
+ } else if (meetingEndDate < nowDate) {
+ const dayOfWeekOptions: Intl.DateTimeFormatOptions = {
+ weekday: 'long',
+ };
+ const monthOptions: Intl.DateTimeFormatOptions = { month: 'long' };
+ const dayOfWeek = new Intl.DateTimeFormat(
+ dateLocale,
+ dayOfWeekOptions
+ ).format(meetingStartDate);
+ const dayOfMonth = meetingStartDate.getDate();
+ const month = new Intl.DateTimeFormat(dateLocale, monthOptions).format(
+ meetingStartDate
+ );
+ const categoryTitle = `${dayOfWeek}, ${month} ${dayOfMonth}`;
+ const categoryId = `category-${meetingEndDateStr}`;
+ if (categoryId in result) {
+ result[categoryId].push(recentMeeting);
+ } else {
+ recentCategoriesRef.current.push({
+ id: categoryId,
+ title: categoryTitle,
+ expanded: false,
+ columns: [],
+ });
+ result[categoryId] = [recentMeeting];
+ }
+ }
+ });
+
+ // Insert relative-date categories into the recentCategories list in a right order if they contain at least one meeting
+ ['lastWeek', 'yesterday', 'today'].forEach((categoryName) => {
+ const categoryId = `category-${categoryName}`;
+ if (result[categoryId].length > 0) {
+ recentCategoriesRef.current.unshift({
+ id: categoryId,
+ title: categoriesTitles[categoryName],
+ expanded: false,
+ columns: [],
+ });
+ }
+ });
+
+ // Determine the number of columns for each category
+ const excludedProperties = ['missed', 'recorded', 'mentionsOfYou'];
+ recentCategoriesRef.current.forEach((category) => {
+ result[category.id].forEach((meeting) => {
+ if (meeting.tasksCount && !category.columns.includes('tasks')) {
+ category.columns.push('tasks');
+ }
+ if (!meeting.properties) {
+ return;
+ }
+ meeting.properties.forEach((property) => {
+ if (
+ !excludedProperties.includes(property) &&
+ !category.columns.includes(property)
+ ) {
+ category.columns.push(property);
+ }
+ });
+ });
+ });
+
+ return result;
+ }, []);
+
+ return (
+ <>
+
+ Variant B: TreeGrid with Enter Inputs
+
+
+ >
+ );
+};
diff --git a/packages/react-tree-grid/stories/VariantB/TreeGridUtils.ts b/packages/react-tree-grid/stories/VariantB/TreeGridUtils.ts
new file mode 100644
index 00000000..80cd7272
--- /dev/null
+++ b/packages/react-tree-grid/stories/VariantB/TreeGridUtils.ts
@@ -0,0 +1,137 @@
+export const useSrNarration = (targetDocument: Document | undefined) => {
+ return (message: string, priority = 'polite') => {
+ if (!targetDocument) {
+ return;
+ }
+ const element = targetDocument.createElement('div');
+ element.setAttribute(
+ 'style',
+ 'position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden;'
+ );
+ element.setAttribute('aria-live', priority);
+ targetDocument.body.appendChild(element);
+
+ setTimeout(() => {
+ element.innerText = message;
+ }, 800);
+
+ setTimeout(() => {
+ targetDocument.body.removeChild(element);
+ }, 1100);
+ };
+};
+
+export const getNearestGridCellAncestorOrSelf = (element: HTMLElement) => {
+ while (element.role !== 'gridcell') {
+ if (element.tagName === 'BODY') {
+ return undefined;
+ }
+ element = element.parentElement as HTMLElement;
+ }
+ return element;
+};
+
+export const getNearestRowAncestor = (element: HTMLElement) => {
+ let parent = element.parentElement as HTMLElement;
+ while (parent.role !== 'row') {
+ parent = parent.parentElement as HTMLElement;
+ }
+ return parent;
+};
+
+export const getFirstCellChild = (element: HTMLElement) => {
+ return element?.querySelectorAll('[role="gridcell"]')[0] as HTMLElement;
+};
+
+export const getFirstActiveElementInVerticalNavigation = (
+ originCell: HTMLElement,
+ targetRow: HTMLElement
+) => {
+ let columnNumber = 1;
+ let cell = originCell;
+ while (cell) {
+ if (
+ cell.previousElementSibling &&
+ cell.previousElementSibling.role === 'gridcell'
+ ) {
+ columnNumber += 1;
+ }
+ cell = cell.previousElementSibling as HTMLElement;
+ }
+ let targetCell = targetRow.querySelector('[role="gridcell"]') as HTMLElement;
+ for (let i = 1; i < columnNumber; i++) {
+ if (targetCell.nextElementSibling) {
+ targetCell = targetCell.nextElementSibling as HTMLElement;
+ }
+ }
+ const firstActiveElement = targetCell.querySelector('button');
+ return firstActiveElement;
+};
+
+export const getNextOrPrevFocusable = (
+ row: HTMLElement,
+ current: HTMLElement | undefined,
+ direction: 'next' | 'prev'
+): HTMLElement | undefined => {
+ const focusables = row.querySelectorAll('a, button, input, select');
+ if (!current && focusables.length >= 1) {
+ return focusables[0] as HTMLElement;
+ }
+ let result;
+ focusables.forEach((focusable, index) => {
+ if (focusable === current) {
+ if (direction === 'next' && index + 1 < focusables.length) {
+ result = focusables[index + 1];
+ } else if (direction === 'prev' && index > 0) {
+ result = focusables[index - 1];
+ }
+ return;
+ }
+ });
+ return result;
+};
+
+export const focusNextOrPrevRow = (
+ currentRow: HTMLElement,
+ event: React.KeyboardEvent
+) => {
+ const table = currentRow.parentElement?.parentElement as HTMLElement;
+ let rowToFocus: HTMLElement | undefined;
+ if (event.key === 'ArrowDown') {
+ const nextTableRow = table.nextElementSibling?.querySelector(
+ '[aria-level="1"]'
+ ) as HTMLElement;
+ if (
+ currentRow.nextElementSibling &&
+ currentRow.nextElementSibling.role === 'row'
+ ) {
+ rowToFocus = currentRow.nextElementSibling as HTMLElement;
+ } else if (nextTableRow) {
+ rowToFocus = nextTableRow;
+ }
+ } else if (event.key === 'ArrowUp') {
+ const prevTableRow = table.previousElementSibling?.querySelector(
+ '[aria-level="1"]'
+ ) as HTMLElement;
+ if (
+ currentRow.previousElementSibling &&
+ currentRow.previousElementSibling.role === 'row'
+ ) {
+ rowToFocus = currentRow.previousElementSibling as HTMLElement;
+ } else if (prevTableRow) {
+ const isPrevTableRowExpanded = prevTableRow.getAttribute('aria-expanded');
+ if (isPrevTableRowExpanded === 'true') {
+ const prevTableRows =
+ table.previousElementSibling?.querySelectorAll('[role="row"]');
+ rowToFocus =
+ prevTableRows &&
+ (prevTableRows[prevTableRows.length - 1] as HTMLElement);
+ } else {
+ rowToFocus = prevTableRow;
+ }
+ }
+ }
+ if (rowToFocus) {
+ (rowToFocus as HTMLElement).focus();
+ }
+};
diff --git a/packages/react-tree-grid/stories/VariantB/TreeGridWithEnterInputsRenderer.tsx b/packages/react-tree-grid/stories/VariantB/TreeGridWithEnterInputsRenderer.tsx
new file mode 100644
index 00000000..dee2f88b
--- /dev/null
+++ b/packages/react-tree-grid/stories/VariantB/TreeGridWithEnterInputsRenderer.tsx
@@ -0,0 +1,245 @@
+import * as React from 'react';
+import {
+ getNearestGridCellAncestorOrSelf,
+ getNearestRowAncestor,
+ getFirstCellChild,
+ focusNextOrPrevRow,
+} from './TreeGridUtils';
+import { useAdamTableInteractive2Navigation } from './useAdamTableInteractive2Navigation';
+import { useFocusableGroup } from '@fluentui/react-tabster';
+import { useFluent_unstable } from '@fluentui/react-shared-contexts';
+
+import {
+ TreeGrid,
+ TreeGridCell,
+ TreeGridRow,
+} from '@fluentui-contrib/react-tree-grid';
+import { Button, Field, Input } from '@fluentui/react-components';
+
+export type RecentCategory = {
+ id: string;
+ title: string;
+ expanded: boolean;
+ columns: string[];
+};
+
+export type RecentMeetings = Record<
+ string,
+ {
+ id: string;
+ title: string;
+ titleWithTime: string;
+ properties?: MeetingProperty[];
+ tasksCount?: number;
+ revealed: boolean;
+ }[]
+>;
+
+export type MeetingProperty =
+ | 'includingContent'
+ | 'transcript'
+ | 'recorded'
+ | 'mentionsOfYou'
+ | 'missed';
+
+interface TreeGridWithEnterInputsRendererProps {
+ recentCategories: RecentCategory[];
+ recentMeetings: RecentMeetings;
+}
+export const TreeGridWithEnterInputsRenderer: React.FC<
+ TreeGridWithEnterInputsRendererProps
+> = ({ recentCategories, recentMeetings }) => {
+ const { targetDocument } = useFluent_unstable();
+ const [recentCategoriesState, setRecentCategoryState] =
+ React.useState(recentCategories);
+
+ const { tableTabsterAttribute, tableRowTabsterAttribute, onTableKeyDown } =
+ useAdamTableInteractive2Navigation();
+ const focusableGroupAttribute = useFocusableGroup({
+ tabBehavior: 'limited-trap-focus',
+ });
+
+ const getCategoryById = React.useCallback(
+ (id: string) => {
+ return recentCategoriesState.find((category) => {
+ return id === category.id;
+ });
+ },
+ [recentCategoriesState]
+ );
+
+ const changeRecentCategoryExpandedState = React.useCallback(
+ (category: RecentCategory | undefined, expanded: boolean) => {
+ if (category) {
+ category.expanded = expanded;
+ }
+ setRecentCategoryState([...recentCategoriesState]);
+ },
+ [recentCategoriesState]
+ );
+
+ const handleRowClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ const currentTarget = event.currentTarget as HTMLElement;
+ const selectedRowId = currentTarget.id;
+ const category = getCategoryById(selectedRowId);
+ changeRecentCategoryExpandedState(category, !category?.expanded);
+ },
+ [getCategoryById, changeRecentCategoryExpandedState]
+ );
+
+ const handleTreeGridKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ let callTabsterKeyboardHandler = true;
+ const isModifierDown =
+ event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
+ if (!isModifierDown) {
+ const target = event.target as HTMLElement;
+ const gridCell = getNearestGridCellAncestorOrSelf(target);
+ if (gridCell) {
+ const row = getNearestRowAncestor(gridCell);
+ const isFirstCellChild = gridCell === getFirstCellChild(row);
+ if (event.key === 'ArrowLeft' && isFirstCellChild) {
+ row.focus();
+ } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ callTabsterKeyboardHandler = false;
+ if (
+ (target.tagName !== 'INPUT' ||
+ target.getAttribute('type') !== 'text') &&
+ target.role !== 'textbox'
+ ) {
+ focusNextOrPrevRow(row, event);
+ }
+ }
+ } else if (target.role === 'row') {
+ const selectedRowId = target.id;
+ const category = getCategoryById(selectedRowId);
+ const level = target.getAttribute('aria-level');
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ focusNextOrPrevRow(target, event);
+ } else if (
+ event.key === 'ArrowRight' &&
+ level === '1' &&
+ category &&
+ !category.expanded
+ ) {
+ changeRecentCategoryExpandedState(category, true);
+ callTabsterKeyboardHandler = false;
+ } else if (event.key === 'ArrowLeft' && level === '1') {
+ changeRecentCategoryExpandedState(category, false);
+ } else if (
+ (event.key === 'Enter' || event.key === ' ') &&
+ level === '1'
+ ) {
+ changeRecentCategoryExpandedState(category, !category?.expanded);
+ } else if (event.key === 'ArrowLeft' && level === '2') {
+ const categoryToFocus = recentCategories.find((testedCategory) => {
+ return !!recentMeetings[testedCategory.id].find((meeting) => {
+ return meeting.id === selectedRowId;
+ });
+ }) as RecentCategory;
+ const categoryRowToFocus = targetDocument?.getElementById(
+ categoryToFocus.id
+ ) as HTMLElement;
+ categoryRowToFocus.focus();
+ }
+ }
+ if (callTabsterKeyboardHandler) {
+ onTableKeyDown(event);
+ }
+ }
+ },
+ [
+ changeRecentCategoryExpandedState,
+ getCategoryById,
+ recentCategories,
+ recentMeetings,
+ onTableKeyDown,
+ targetDocument,
+ ]
+ );
+
+ return (
+
+ {recentCategories.map((category, index) => (
+
+
+ {category.title}
+
+
+
+
+ {category.expanded &&
+ recentMeetings[category.id].map((meeting) => (
+
+
+ {meeting.titleWithTime}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {category.columns.includes('includingContent') && (
+
+ {meeting.properties?.includes('includingContent') && (
+
+ )}
+
+ )}
+ {category.columns.includes('tasks') && (
+
+ {meeting.tasksCount && (
+
+ )}
+
+ )}
+ {category.columns.includes('transcript') && (
+
+ {meeting.properties?.includes('transcript') && (
+
+ )}
+
+ )}
+
+ ))}
+
+ ))}
+
+ );
+};
diff --git a/packages/react-tree-grid/stories/VariantB/index.ts b/packages/react-tree-grid/stories/VariantB/index.ts
new file mode 100644
index 00000000..ec9ef9aa
--- /dev/null
+++ b/packages/react-tree-grid/stories/VariantB/index.ts
@@ -0,0 +1 @@
+export * from './TreeGridWithEnterInputsRenderer';
diff --git a/packages/react-tree-grid/stories/VariantB/useAdamTableInteractive2Navigation.ts b/packages/react-tree-grid/stories/VariantB/useAdamTableInteractive2Navigation.ts
new file mode 100644
index 00000000..684765e9
--- /dev/null
+++ b/packages/react-tree-grid/stories/VariantB/useAdamTableInteractive2Navigation.ts
@@ -0,0 +1,105 @@
+import * as React from 'react';
+import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
+import {
+ ArrowDown,
+ ArrowRight,
+ Escape,
+ keyCodes,
+ ArrowUp,
+} from '@fluentui/keyboard-keys';
+import {
+ useArrowNavigationGroup,
+ useFocusableGroup,
+ useMergedTabsterAttributes_unstable,
+ TabsterDOMAttribute,
+ useFocusFinders,
+} from '@fluentui/react-tabster';
+import { isHTMLElement } from '@fluentui/react-utilities';
+
+export function useAdamTableInteractive2Navigation(): {
+ onTableKeyDown: React.KeyboardEventHandler;
+ tableTabsterAttribute: TabsterDOMAttribute;
+ tableRowTabsterAttribute: TabsterDOMAttribute;
+} {
+ const horizontalAttr = useArrowNavigationGroup({
+ axis: 'horizontal',
+ memorizeCurrent: true,
+ });
+ const gridAttr = useArrowNavigationGroup({
+ axis: 'grid',
+ memorizeCurrent: true,
+ });
+ const groupperAttr = useFocusableGroup({
+ tabBehavior: 'limited-trap-focus',
+ ignoreDefaultKeydown: { ArrowDown: true, ArrowUp: true },
+ });
+ const { findFirstFocusable } = useFocusFinders();
+ const { targetDocument } = useFluent();
+
+ const rowAttr = useMergedTabsterAttributes_unstable(
+ horizontalAttr,
+ groupperAttr
+ );
+
+ const onKeyDown: React.KeyboardEventHandler = React.useCallback(
+ (e) => {
+ if (!targetDocument) {
+ return;
+ }
+
+ const activeElement = targetDocument.activeElement;
+ if (!activeElement || !e.currentTarget.contains(activeElement)) {
+ return;
+ }
+ const activeElementRole = activeElement.getAttribute('role');
+
+ // Enter groupper when in row focus mode to navigate cells
+ if (
+ e.key === ArrowRight &&
+ activeElementRole === 'row' &&
+ isHTMLElement(activeElement)
+ ) {
+ findFirstFocusable(activeElement)?.focus();
+ }
+
+ if (activeElementRole === 'row') {
+ return;
+ }
+
+ const isInCell = (() => {
+ let cur = isHTMLElement(activeElement) ? activeElement : null;
+ while (cur) {
+ const curRole = cur.getAttribute('role');
+ if (curRole === 'cell' || curRole === 'gridcell') {
+ return true;
+ }
+
+ cur = cur.parentElement;
+ }
+
+ return false;
+ })();
+
+ // Escape groupper focus trap before arrow down
+ if ((e.key === ArrowDown || e.key === ArrowUp) && isInCell) {
+ activeElement.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: Escape,
+ keyCode: keyCodes.Escape,
+ })
+ );
+ // Tabster uses keycodes
+ activeElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: e.key, keyCode: e.keyCode })
+ );
+ }
+ },
+ [targetDocument, findFirstFocusable]
+ );
+
+ return {
+ onTableKeyDown: onKeyDown,
+ tableTabsterAttribute: gridAttr,
+ tableRowTabsterAttribute: rowAttr,
+ };
+}
diff --git a/packages/react-tree-grid/stories/index.stories.tsx b/packages/react-tree-grid/stories/index.stories.tsx
new file mode 100644
index 00000000..83ba2bcd
--- /dev/null
+++ b/packages/react-tree-grid/stories/index.stories.tsx
@@ -0,0 +1,11 @@
+import { Meta } from '@storybook/react';
+import { TreeGrid } from '@fluentui-contrib/react-tree-grid';
+
+export { VariantB } from './TreeGridVariantB.stories';
+
+const meta: Meta = {
+ title: 'TreeGrid',
+ component: TreeGrid,
+};
+
+export default meta;
diff --git a/packages/react-tree-grid/tsconfig.json b/packages/react-tree-grid/tsconfig.json
new file mode 100644
index 00000000..15d0c1ca
--- /dev/null
+++ b/packages/react-tree-grid/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "files": [],
+ "compilerOptions": {
+ "jsx": "react"
+ },
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ },
+ {
+ "path": "./.storybook/tsconfig.json"
+ }
+ ]
+}
diff --git a/packages/react-tree-grid/tsconfig.lib.json b/packages/react-tree-grid/tsconfig.lib.json
new file mode 100644
index 00000000..590b0063
--- /dev/null
+++ b/packages/react-tree-grid/tsconfig.lib.json
@@ -0,0 +1,18 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "declaration": true
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": [
+ "jest.config.ts",
+ "src/**/*.test.ts",
+ "src/**/*.test.tsx",
+ "files/**",
+ "**/*.stories.ts",
+ "**/*.stories.js",
+ "**/*.stories.jsx",
+ "**/*.stories.tsx"
+ ]
+}
diff --git a/packages/react-tree-grid/tsconfig.spec.json b/packages/react-tree-grid/tsconfig.spec.json
new file mode 100644
index 00000000..361fd5a7
--- /dev/null
+++ b/packages/react-tree-grid/tsconfig.spec.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "module": "commonjs",
+ "types": ["jest", "node"]
+ },
+ "include": [
+ "jest.config.ts",
+ "src/**/*.test.ts",
+ "src/**/*.test.tsx",
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 7c432574..47b6f178 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -34,6 +34,9 @@
"packages/react-resize-handle/src/index.ts"
],
"@fluentui-contrib/react-shadow": ["packages/react-shadow/src/index.ts"],
+ "@fluentui-contrib/react-tree-grid": [
+ "packages/react-tree-grid/src/index.ts"
+ ],
"@fluentui-contrib/stylelint-plugin": [
"packages/stylelint-plugin/src/index.ts"
]