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" ]