From 2e478272307ec49a5d94d2348b34a59273c412ea Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 7 May 2026 14:09:58 +0200 Subject: [PATCH 1/3] feat(react-tree-grid): improve virtualization story --- .../stories/Virtualization.stories.tsx | 832 ++++++++++++++---- 1 file changed, 645 insertions(+), 187 deletions(-) diff --git a/packages/react-tree-grid/stories/Virtualization.stories.tsx b/packages/react-tree-grid/stories/Virtualization.stories.tsx index 07536db87..895bfc2c3 100644 --- a/packages/react-tree-grid/stories/Virtualization.stories.tsx +++ b/packages/react-tree-grid/stories/Virtualization.stories.tsx @@ -3,154 +3,71 @@ import { TreeGrid, TreeGridCell, TreeGridRow, - TreeGridRowProvider, TreeGridRowOnOpenChangeData, + TreeGridRowProvider, } from '@fluentui-contrib/react-tree-grid'; import { + Avatar, + Body1Stronger, Button, - Menu, - MenuItem, - MenuList, - MenuPopover, - MenuTrigger, - useEventCallback, - HeadlessFlatTreeItemProps, - ForwardRefComponent, + Caption1, + Caption1Stronger, + InteractionTag, + InteractionTagPrimary, + Image, + Tag, + Tooltip, makeStyles, + mergeClasses, shorthands, + tokens, + useEventCallback, useFluent, } from '@fluentui/react-components'; -import { isHTMLElement } from '@fluentui/react-utilities'; - import { - FixedSizeList, - FixedSizeListProps, - ListChildComponentProps, -} from 'react-window'; -import { TreeGridProps } from '../src/components/TreeGrid/TreeGrid.types'; + AttachRegular, + CalendarRegular, + CaretDownFilled, + CaretRightFilled, + CheckmarkCircleRegular, +} from '@fluentui/react-icons'; import { ArrowLeft } from '@fluentui/keyboard-keys'; +import { isHTMLElement } from '@fluentui/react-utilities'; +import { ListChildComponentProps, VariableSizeList } from 'react-window'; -const useStyles = makeStyles({ - cell: { - ...shorthands.flex(1, 1, '100%'), - }, -}); - -type Item = { - children: string; +type SectionItem = { + type: 'section'; + rowType: 'section'; value: string; - parentValue?: string; -}; -const defaultItems: Item[] = [ - { - value: 'flatTreeItem_lvl-1_item-1', - children: `Level 1, item 1`, - }, - ...Array.from({ length: 300 }, (_, i) => ({ - value: `flatTreeItem_lvl-1_item-1--child:${i}`, - parentValue: 'flatTreeItem_lvl-1_item-1', - children: `Item ${i + 1}`, - })), - { - value: 'flatTreeItem_lvl-1_item-2', - children: `Level 1, item 2`, - }, - ...Array.from({ length: 300 }, (_, index) => ({ - value: `flatTreeItem_lvl-1_item-2--child:${index}`, - parentValue: 'flatTreeItem_lvl-1_item-2', - children: `Item ${index + 1}`, - })), -]; - -type FixedSizeTreeGridProps = Omit & { - listProps: FixedSizeListProps & { ref?: React.Ref }; + header: string; + meetingCount: number; }; -const FixedSizeTreeGrid: ForwardRefComponent = - React.forwardRef((props, ref) => { - const { listProps, ...rest } = props; - const handleRef = React.useCallback((instance: HTMLElement | null) => { - if (instance) { - // This element stays between the treegrid and row - // Due to accessibility issues this element should have role="none" - instance.setAttribute('role', 'none'); - } - }, []); - return ( - - - - ); - }); +type MeetingRowType = + | 'summary' + | 'summaryWithBadges' + | 'detail' + | 'detailWithBadges' + | 'preview' + | 'previewWithBadges'; -type FixedSizeTreeGridRowProps = ListChildComponentProps< - HeadlessFlatTreeItemProps[] ->; +type MeetingItem = { + type: 'meeting'; + rowType: MeetingRowType; + value: string; + parentValue: string; + header: string; + location: string; + owner: string; + description?: string; + status?: 'missed'; + tasks?: number; + attachments?: number; + hasThumbnail: boolean; + thumbnailLabel: string; +}; -const FixedSizeTreeGridRow = React.memo((props: FixedSizeTreeGridRowProps) => { - const styles = useStyles(); - const item = props.data[props.index]; - const { openItems, requestOpenChange } = useVirtualizationContext(); - return item.parentValue === undefined ? ( - - requestOpenChange({ ...data, index: props.index }) - } - style={props.style} - subtree - > - - {item.children} - - - - - - ) : ( - - - - {item.children} - - - - - - - - - - - - - New - New Window - Open File - Open Folder - - - - - - - - - - ); -}); +type VirtualizedMeetingsItem = SectionItem | MeetingItem; type VirtualizationContextValue = { openItems: Map; @@ -159,11 +76,326 @@ type VirtualizationContextValue = { ) => void; }; +const rowFocusGap = 8; + +const dayHeaders = [ + 'Thursday, 1 February', + 'Wednesday, 31 January', + 'Tuesday, 30 January', + 'Monday, 29 January', + 'Friday, 26 January', + 'Thursday, 25 January', + 'Wednesday, 24 January', + 'Tuesday, 23 January', + 'Monday, 22 January', + 'Friday, 19 January', + 'Thursday, 18 January', + 'Wednesday, 17 January', + 'Tuesday, 16 January', + 'Monday, 15 January', + 'Friday, 12 January', + 'Thursday, 11 January', + 'Wednesday, 10 January', + 'Tuesday, 9 January', +]; + +const meetingTitles = [ + 'All Hands with Sanjay Garg', + 'Monthly Sync with Fluent Team', + 'TAX RETURN 2023: How to Prepare | Online Training', + 'CAP January Top of Mind with Jeff Teper', + 'Design Review for TreeGrid Virtualization', + 'Weekly Recap with Product Design', + 'Partner Readiness Office Hours', + 'Engineering Health Dashboard Review', +]; + +const meetingOwners = [ + 'Lenka Klugarova', + 'Amit Sehgal', + 'CZSK Comms', + 'Collaborative Apps and Platforms Executive Calendar', + 'Miriam Chen', + 'Alex Wilber', + 'Megan Bowen', + 'Ravi Narayan', +]; + +const meetingDescriptions = [ + 'Meeting summary is currently not available for this meeting.', + 'Recap is processing and should be available shortly after the recording is indexed.', + 'Shared notes, tasks, and files are attached to help participants catch up quickly.', + undefined, +]; + +const timeRanges = [ + '08:30 - 09:00', + '09:00 - 09:30', + '09:30 - 10:15', + '10:30 - 11:00', + '11:05 - 12:00', + '12:30 - 13:00', + '13:00 - 13:45', + '14:00 - 14:30', + '15:00 - 15:45', + '16:00 - 16:30', + '16:30 - 17:30', + '18:00 - 18:30', +]; + +const thumbnailSources = [ + 'https://placehold.co/130x70/E1F0FF/0F6CBD?text=Recap', + 'https://placehold.co/130x70/FDE7E9/C4314B?text=Recording', + 'https://placehold.co/130x70/E9F7EF/107C41?text=Notes', + 'https://placehold.co/130x70/FFF4CE/8A6D1E?text=Slides', +]; + +const useStyles = makeStyles({ + story: { + maxWidth: '1200px', + width: '100%', + overflowX: 'hidden', + }, + treeGrid: { + width: '100%', + overflowX: 'hidden', + }, + section: { + display: 'grid', + gridTemplateColumns: 'auto minmax(0, 1fr) auto', + alignItems: 'center', + minHeight: '48px', + backgroundColor: tokens.colorNeutralBackground2, + ...shorthands.padding( + 0, + tokens.spacingHorizontalM, + 0, + tokens.spacingHorizontalMNudge + ), + boxSizing: 'border-box', + width: '100%', + cursor: 'pointer', + }, + sectionLabel: { + display: 'grid', + rowGap: tokens.spacingVerticalXXS, + }, + sectionMeta: { + color: tokens.colorNeutralForeground3, + }, + sectionChevron: { + color: tokens.colorNeutralForeground3, + ...shorthands.margin(0, tokens.spacingHorizontalS, 0, 0), + }, + sectionCount: { + justifySelf: 'end', + }, + sectionItem: { + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 220px)', + gridTemplateRows: 'repeat(2, auto)', + alignItems: 'start', + columnGap: '0.5rem', + rowGap: '0.75rem', + backgroundColor: tokens.colorNeutralBackground1, + ...shorthands.padding('0.75rem'), + boxSizing: 'border-box', + minWidth: 0, + width: '100%', + ':hover': { + backgroundColor: tokens.colorNeutralBackground2Hover, + }, + }, + container: { + display: 'grid', + gridTemplateColumns: 'auto minmax(0, 1fr) auto', + gridTemplateRows: 'repeat(3, auto)', + gridAutoFlow: 'row', + gridTemplateAreas: ` + 'icon title tag' + 'icon location location' + 'icon description description' + `, + alignItems: 'center', + rowGap: '0.5rem', + columnGap: '0.5rem', + alignSelf: 'baseline', + justifySelf: 'baseline', + minWidth: 0, + }, + title: { + alignSelf: 'start', + justifySelf: 'start', + minWidth: 0, + ...shorthands.gridArea('title'), + }, + icon: { + ...shorthands.gridArea('icon'), + ...shorthands.margin(0, '1rem', 0, '0.6rem'), + alignSelf: 'flex-start', + }, + tag: shorthands.gridArea('tag'), + location: { + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + ...shorthands.gridArea('location'), + }, + description: { + color: tokens.colorNeutralForeground3, + minWidth: 0, + ...shorthands.gridArea('description'), + }, + header: { + ...shorthands.gridArea(1, 1, 3, 2), + minWidth: 0, + }, + noPadding: { + ...shorthands.padding(0), + }, + titleButton: { + justifyContent: 'flex-start', + minWidth: 0, + }, + titleText: { + display: 'block', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + missedTag: { + ...shorthands.border('1px', 'solid', tokens.colorPaletteRedBorder1), + backgroundColor: tokens.colorPaletteRedBackground1, + color: tokens.colorPaletteRedForeground1, + }, + actionCell: { + display: 'flex', + alignItems: 'center', + minWidth: 0, + }, + actionPanel: { + ...shorthands.gridArea(1, 2, 3, 3), + display: 'grid', + justifyItems: 'end', + alignContent: 'start', + rowGap: '0.75rem', + minWidth: 0, + width: '100%', + maxWidth: '220px', + }, + quickActions: { + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'flex-end', + gap: tokens.spacingHorizontalS, + minWidth: 0, + width: '100%', + maxWidth: '220px', + }, + actionButton: { + minWidth: 'unset', + }, + previewButton: { + justifySelf: 'end', + width: '130px', + maxWidth: '100%', + }, + previewImage: { + display: 'block', + width: '100%', + maxWidth: '100%', + height: '70px', + objectFit: 'cover', + }, +}); + +const allItems: VirtualizedMeetingsItem[] = dayHeaders.flatMap( + (header, dayIndex) => { + const sectionValue = `meeting-day-${dayIndex}`; + const meetings = Array.from({ length: 22 }, (_, meetingIndex) => { + const titleIndex = (dayIndex * 3 + meetingIndex) % meetingTitles.length; + const ownerIndex = (dayIndex + meetingIndex) % meetingOwners.length; + const descriptionIndex = + (dayIndex * 2 + meetingIndex) % meetingDescriptions.length; + const timeIndex = (dayIndex + meetingIndex) % timeRanges.length; + const tasks = + meetingIndex % 4 === 0 + ? ((meetingIndex + dayIndex) % 8) + 2 + : undefined; + const attachments = + meetingIndex % 3 === 0 + ? ((meetingIndex + dayIndex) % 4) + 1 + : undefined; + const hasThumbnail = meetingIndex % 2 === 0; + const description = meetingDescriptions[descriptionIndex]; + const hasBadges = Boolean(tasks) && Boolean(attachments); + + let rowType: MeetingRowType; + if (hasThumbnail) { + rowType = hasBadges ? 'previewWithBadges' : 'preview'; + } else if (description) { + rowType = hasBadges ? 'detailWithBadges' : 'detail'; + } else { + rowType = hasBadges ? 'summaryWithBadges' : 'summary'; + } + + return { + type: 'meeting' as const, + rowType, + value: `${sectionValue}-meeting-${meetingIndex}`, + parentValue: sectionValue, + header: + meetingIndex % 5 === 0 + ? `${meetingTitles[titleIndex]} ${meetingIndex + 1}` + : meetingTitles[titleIndex], + location: `${header} · ${timeRanges[timeIndex]}`, + owner: meetingOwners[ownerIndex], + description, + status: + (dayIndex + meetingIndex) % 9 === 0 ? ('missed' as const) : undefined, + tasks, + attachments, + hasThumbnail, + thumbnailLabel: ['Recap', 'Recording', 'Notes', 'Slides'][ + (dayIndex + meetingIndex) % 4 + ], + }; + }); + + return [ + { + type: 'section' as const, + rowType: 'section' as const, + value: sectionValue, + header, + meetingCount: meetings.length, + }, + ...meetings, + ]; + } +); + +const defaultOpenItems = new Map( + allItems.flatMap((item, index) => { + if (item.type !== 'section' || index > 46) { + return []; + } + + return [[item.value, index] as const]; + }) +); + +const getItemKey = ( + index: number, + items: VirtualizedMeetingsItem[] +): React.Key => items[index].value; + const VirtualizationContext = React.createContext< VirtualizationContextValue | undefined >(undefined); -const useVirtualizationContext = () => { +const useVirtualizationContext = (): VirtualizationContextValue => { const context = React.useContext(VirtualizationContext); if (!context) { throw new Error( @@ -173,87 +405,313 @@ const useVirtualizationContext = () => { return context; }; -export const Virtualization = () => { +const renderCountTag = ( + count: number, + label: string, + icon: React.ReactElement, + appearance?: 'brand' +): React.ReactElement => ( + + + + {count} + + + +); + +const getMeetingRowSize = (item: MeetingItem): number => { + switch (item.rowType) { + case 'previewWithBadges': + return 164; + case 'preview': + return 152; + case 'detailWithBadges': + return 124; + case 'detail': + return 112; + case 'summaryWithBadges': + return 104; + case 'summary': + return 92; + } +}; + +const VirtualizedMeetingsRow = React.memo( + ( + props: ListChildComponentProps + ): React.ReactElement => { + const styles = useStyles(); + const item = props.data[props.index]; + const { openItems, requestOpenChange } = useVirtualizationContext(); + const rowStyle: React.CSSProperties = { + ...props.style, + width: `calc(100% - ${rowFocusGap * 2}px)`, + marginInline: `${rowFocusGap}px`, + }; + + if (item.type === 'section') { + const isOpen = openItems.get(item.value) !== undefined; + + return ( + + requestOpenChange({ ...data, index: props.index }) + } + open={isOpen} + style={rowStyle} + subtree + > + {isOpen ? ( + + ) : ( + + )} + +
+ {item.header} + + {item.meetingCount} meetings in this section + +
+
+ + {renderCountTag( + item.meetingCount, + 'meetings', + , + 'brand' + )} + +
+ ); + } + + const thumbnailSource = + thumbnailSources[props.index % thumbnailSources.length]; + + return ( + + + + } + /> + + {item.status === 'missed' ? ( + + Missed + + ) : null} + + {item.location}, {item.owner} + + {item.description ? ( + + {item.description} + + ) : null} + + +
+ {item.tasks + ? renderCountTag( + item.tasks, + 'tasks for people to follow up on', + , + 'brand' + ) + : null} + {item.attachments + ? renderCountTag(item.attachments, 'files', ) + : null} + + +
+ {item.hasThumbnail ? ( + + ) : null} +
+
+
+ ); + } +); + +export const Virtualization = (): React.ReactElement => { + const styles = useStyles(); const { targetDocument: doc } = useFluent(); const win = doc?.defaultView; + const listRef = React.useRef(null); const [openItems, setOpenItems] = React.useState( - () => new Map() + () => new Map(defaultOpenItems) ); const requestOpenChange = useEventCallback( (data: TreeGridRowOnOpenChangeData & { index: number }) => { const row = data.event.currentTarget; - if (isHTMLElement(row)) { - const id = row.dataset.itemId; - if (id) { - setOpenItems((prev) => { - const next = new Map(prev); - if (data.open) { - next.set(id, data.index); - } else { - next.delete(id); - } - return next; - }); - } + if (!isHTMLElement(row)) { + return; } + + const id = row.dataset.itemId; + if (!id) { + return; + } + + setOpenItems((prev) => { + const next = new Map(prev); + if (data.open) { + next.set(id, data.index); + } else { + next.delete(id); + } + return next; + }); } ); const visibleItems = React.useMemo( () => - defaultItems.filter( + allItems.filter( (item) => - item.parentValue === undefined || + item.type === 'section' || openItems.get(item.parentValue) !== undefined ), [openItems] ); - const listRef = React.useRef(null); + const getItemSize = React.useCallback( + (index: number): number => { + const item = visibleItems[index]; + if (item.rowType === 'section') { + return 48; + } + + return getMeetingRowSize(item); + }, + [visibleItems] + ); + + React.useEffect(() => { + listRef.current?.resetAfterIndex(0); + }, [visibleItems]); + + const handleListBoundaryRef = React.useCallback( + (instance: HTMLElement | null): void => { + if (instance) { + instance.setAttribute('role', 'none'); + } + }, + [] + ); const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - if (event.key === ArrowLeft && isHTMLElement(event.target)) { - const parentId = event.target.dataset.itemParentId; - if (!parentId) { - return; - } - const index = openItems.get(parentId); - if (index !== undefined && win && doc) { - listRef.current?.scrollToItem(index, 'smart'); - win.requestAnimationFrame(() => { - doc - .querySelector(`[data-item-id="${parentId}"]`) - ?.focus(); - }); - } + (event: React.KeyboardEvent): void => { + if (event.key !== ArrowLeft || !isHTMLElement(event.target)) { + return; + } + + const row = event.target.closest('[role="row"]'); + const parentId = row?.dataset.itemParentId; + if (!parentId) { + return; } + + const index = openItems.get(parentId); + if (index === undefined || !win || !doc) { + return; + } + + listRef.current?.scrollToItem(index, 'smart'); + win.requestAnimationFrame(() => { + doc.querySelector(`[data-item-id="${parentId}"]`)?.focus(); + }); }, - [openItems] + [doc, openItems, win] + ); + + const contextValue = React.useMemo( + () => ({ openItems, requestOpenChange }), + [openItems, requestOpenChange] ); return ( - ({ openItems, requestOpenChange }), - [openItems] - )} - > - - +
+ + + + {VirtualizedMeetingsRow} + + + +
); }; From c4f37bc4c2148763e1e27be4589857a8c3a47f76 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Mon, 18 May 2026 09:28:50 +0200 Subject: [PATCH 2/3] feat(react-tree-grid): add threaded virtualization story --- .../ThreadedVirtualization.investigation.md | 26 + .../ThreadedVirtualization.stories.tsx | 1125 +++++++++++++++++ .../react-tree-grid/stories/index.stories.tsx | 8 +- 3 files changed, 1158 insertions(+), 1 deletion(-) create mode 100644 packages/react-tree-grid/stories/ThreadedVirtualization.investigation.md create mode 100644 packages/react-tree-grid/stories/ThreadedVirtualization.stories.tsx diff --git a/packages/react-tree-grid/stories/ThreadedVirtualization.investigation.md b/packages/react-tree-grid/stories/ThreadedVirtualization.investigation.md new file mode 100644 index 000000000..2434ee773 --- /dev/null +++ b/packages/react-tree-grid/stories/ThreadedVirtualization.investigation.md @@ -0,0 +1,26 @@ +## Threaded Virtualization Investigation + +This example reflects a navigation and hierarchy shape we want to support, but it also highlights API gaps in the current `TreeGrid` primitives. + +### Navigation gaps + +- The example needs story-level `onKeyDown` handlers for header traversal and entry or exit focus behavior. +- Tabster interception is acceptable here, but custom key routing for row navigation is a smell and suggests missing `TreeGrid` navigation extension points. +- The example needs its own focus registry and `scrollToItem` coordination for virtualization. + +### Hierarchy and ARIA gaps + +- The example sets structural ARIA such as `aria-level` and `aria-expanded` directly on rows. +- This happens because `TreeGridRow` currently couples hierarchical semantics to the rendered subtree pattern. +- Virtualized flattened trees still need the same semantics even when child rows are not rendered as a DOM subtree. + +### Candidate areas to investigate + +- A configurable navigation strategy for `TreeGrid` so examples do not have to reimplement row-to-row keyboard routing. +- Row classification metadata, such as header versus message versus input, so navigation and semantics can vary by row kind. +- Declarative row hierarchy metadata on `TreeGridRow` so consumers provide level and expandability state without manually setting ARIA. +- A first-class virtualization focus contract so consumers can resolve and focus items that are not currently mounted. + +### Goal + +Use this example as a reference for future `TreeGrid` API improvements, not as a signal that complex behavior should live permanently in story code. diff --git a/packages/react-tree-grid/stories/ThreadedVirtualization.stories.tsx b/packages/react-tree-grid/stories/ThreadedVirtualization.stories.tsx new file mode 100644 index 000000000..089dc87f4 --- /dev/null +++ b/packages/react-tree-grid/stories/ThreadedVirtualization.stories.tsx @@ -0,0 +1,1125 @@ +import * as React from 'react'; +import { + TreeGrid, + TreeGridCell, + TreeGridInteraction, + TreeGridRow, + TreeGridRowOnOpenChangeData, + TreeGridRowProvider, +} from '@fluentui-contrib/react-tree-grid'; +import { + Avatar, + Body1Stronger, + Button, + Caption1, + Link, + Textarea, + makeStyles, + mergeClasses, + shorthands, + tokens, + useEventCallback, + useFluent, +} from '@fluentui/react-components'; +import { CaretDownFilled, CaretRightFilled } from '@fluentui/react-icons'; +import { + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, + End, + Enter, + Home, +} from '@fluentui/keyboard-keys'; +import { useTabsterAttributes } from '@fluentui/react-tabster'; +import { isHTMLElement } from '@fluentui/react-utilities'; +import { ListChildComponentProps, VariableSizeList } from 'react-window'; + +type ThreadHeaderItem = { + type: 'thread-header'; + rowType: 'threadHeader'; + value: string; + header: string; + messageCount: number; + lastUpdated: string; +}; + +type ThreadMessageItem = { + type: 'thread-message'; + rowType: 'threadMessage'; + value: string; + parentValue: string; + author: string; + location: string; + preview: string; + timestamp: string; + isUnread?: boolean; +}; + +type ThreadInputItem = { + type: 'thread-input'; + rowType: 'threadInput'; + value: string; + parentValue: string; +}; + +type ThreadedItem = ThreadHeaderItem | ThreadMessageItem | ThreadInputItem; + +type ThreadedVirtualizationContextValue = { + focusedHeaderId: string | undefined; + openItems: Map; + requestOpenChange: ( + data: TreeGridRowOnOpenChangeData & { index: number } + ) => void; + setFocusedHeaderId: React.Dispatch>; + focusNextHeader: (threadId: string) => void; + focusPrevHeader: (threadId: string) => void; + focusFirstItem: () => void; + focusLastItem: () => void; + focusUnread: (threadId: string) => void; + focusInput: (threadId: string) => void; + registerElementRef: (id: string, element: HTMLElement | null) => void; +}; + +const rowFocusGap = 8; +const threadHeaderHeight = 72; +const threadMessageHeight = 112; +const threadInputHeight = 92; +const threadHeaderGap = 16; +const containerHeight = 720; +const headerPreventedKeys = [Home, End, ArrowUp, ArrowDown, Enter]; + +const threadSeeds = [ + { + id: 'thread-401', + header: 'Design critique follow-up', + lastUpdated: '2m ago', + messages: [ + { + author: 'Adele Vance', + location: 'Design team · General', + preview: + 'Shared the latest compositional layout and asked for alignment on navigation density before handoff.', + timestamp: '2m', + }, + { + author: 'Megan Bowen', + location: 'Design team · General', + preview: + 'Can we keep the header pinned while allowing child content to virtualize independently?', + timestamp: '5m', + }, + { + author: 'Ravi Narayan', + location: 'Design team · General', + preview: + 'Yes, but the keyboard model needs to preserve thread semantics rather than row-only navigation.', + timestamp: '9m', + }, + ], + }, + { + id: 'thread-402', + header: 'Unread navigation requirements', + lastUpdated: '12m ago', + messages: [ + { + author: 'Lenka Klugarova', + location: 'Client review · Requirements', + preview: + 'The client expects Space on a focused header to jump to the next unread message in that thread.', + timestamp: '12m', + }, + { + author: 'Amit Sehgal', + location: 'Client review · Requirements', + preview: + 'ArrowUp and ArrowDown should skip child rows and move between thread headers only.', + timestamp: '16m', + }, + { + author: 'CZSK Comms', + location: 'Client review · Requirements', + preview: + 'Cmd+R and Ctrl+R need to jump directly into the reply box of the active thread.', + timestamp: '18m', + }, + { + author: 'Jenny Lay-Flurrie', + location: 'Client review · Requirements', + preview: + 'This is the message we would mark as unread in the story so the shortcut has something concrete to target.', + timestamp: '21m', + }, + ], + }, + { + id: 'thread-403', + header: 'Virtualization implementation notes', + lastUpdated: '34m ago', + messages: [ + { + author: 'Alex Wilber', + location: 'Engineering · Architecture', + preview: + 'We can keep a flattened visible-items array and still preserve thread-level semantics with explicit row types.', + timestamp: '34m', + }, + { + author: 'Miriam Chen', + location: 'Engineering · Architecture', + preview: + 'The row heights should stay deterministic here. Live measurement caused too much visible recaching.', + timestamp: '42m', + }, + ], + }, + { + id: 'thread-404', + header: 'Reply interactions', + lastUpdated: '1h ago', + messages: [ + { + author: 'Nora Diaz', + location: 'Messaging · Interaction', + preview: + 'The input row should behave like interactive content within the tree grid and keep the escape hatch obvious.', + timestamp: '1h', + }, + { + author: 'Diego Siciliani', + location: 'Messaging · Interaction', + preview: + 'We should reserve space for the actions so hover and focus do not cause the layout to shift.', + timestamp: '1h', + }, + { + author: 'Kevin Scott', + location: 'Messaging · Interaction', + preview: + 'This thread intentionally has one more message so the scroll behavior is easy to test at different offsets.', + timestamp: '1h', + }, + { + author: 'Isaac Newton', + location: 'Messaging · Interaction', + preview: + 'Open the docs link to review the background notes for this experiment.', + timestamp: '1h', + }, + ], + }, +]; + +const allItems: ThreadedItem[] = threadSeeds.flatMap((thread) => { + const threadMessages = thread.messages.map((message, index) => ({ + type: 'thread-message' as const, + rowType: 'threadMessage' as const, + value: `${thread.id}--message-${index + 1}`, + parentValue: thread.id, + author: message.author, + location: message.location, + preview: message.preview, + timestamp: message.timestamp, + isUnread: index === thread.messages.length - 2, + })); + + return [ + { + type: 'thread-header' as const, + rowType: 'threadHeader' as const, + value: thread.id, + header: thread.header, + messageCount: thread.messages.length, + lastUpdated: thread.lastUpdated, + }, + ...threadMessages, + { + type: 'thread-input' as const, + rowType: 'threadInput' as const, + value: `${thread.id}--input`, + parentValue: thread.id, + }, + ]; +}); + +const defaultOpenItems = new Map( + allItems.flatMap((item, index) => + item.type === 'thread-header' ? [[item.value, index] as const] : [] + ) +); + +const getItemKey = (index: number, items: ThreadedItem[]): React.Key => + items[index].value; + +const getFocusableItemId = (item: ThreadedItem): string => + item.type === 'thread-input' ? `${item.parentValue}-input` : item.value; + +const getThreadedItemSize = ( + item: ThreadedItem, + index: number, + items: ThreadedItem[] +): number => { + const previousItem = items[index - 1]; + const leadingGap = + item.rowType === 'threadHeader' && previousItem ? threadHeaderGap : 0; + + switch (item.rowType) { + case 'threadHeader': + return threadHeaderHeight + leadingGap; + case 'threadInput': + return threadInputHeight; + case 'threadMessage': + return threadMessageHeight; + } +}; + +const useStyles = makeStyles({ + story: { + maxWidth: '1180px', + width: '100%', + overflowX: 'hidden', + }, + infoBox: { + display: 'inline-flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: tokens.spacingHorizontalL, + marginBottom: tokens.spacingVerticalM, + ...shorthands.padding(tokens.spacingVerticalS, tokens.spacingHorizontalM), + ...shorthands.borderRadius(tokens.borderRadiusMedium), + ...shorthands.border('1px', 'solid', tokens.colorNeutralStroke1), + backgroundColor: tokens.colorNeutralBackground2, + }, + infoLabel: { + color: tokens.colorNeutralForeground3, + }, + focusedThreadId: { + color: tokens.colorStatusDangerForeground1, + }, + keyHint: { + color: tokens.colorNeutralForeground2, + }, + treeGrid: { + width: '100%', + overflowX: 'hidden', + }, + rowFrame: { + boxSizing: 'border-box', + width: '100%', + }, + headerRow: { + display: 'grid', + gridTemplateColumns: 'auto minmax(0, 1fr) auto', + alignItems: 'center', + minHeight: `${threadHeaderHeight}px`, + backgroundColor: tokens.colorNeutralBackground6, + ...shorthands.padding(tokens.spacingVerticalM, tokens.spacingHorizontalL), + boxSizing: 'border-box', + width: '100%', + cursor: 'pointer', + ':hover': { + backgroundColor: tokens.colorNeutralBackground4Selected, + }, + }, + headerRowOutlined: { + boxShadow: `inset 2px 0 0 0 ${tokens.colorStrokeFocus2}, inset -2px 0 0 0 ${tokens.colorStrokeFocus2}, inset 0 2px 0 0 ${tokens.colorStrokeFocus2}`, + }, + headerRowOutlinedLast: { + boxShadow: `inset 2px 0 0 0 ${tokens.colorStrokeFocus2}, inset -2px 0 0 0 ${tokens.colorStrokeFocus2}, inset 0 2px 0 0 ${tokens.colorStrokeFocus2}, inset 0 -2px 0 0 ${tokens.colorStrokeFocus2}`, + }, + headerChevron: { + color: tokens.colorNeutralForeground3, + ...shorthands.margin(0, tokens.spacingHorizontalS, 0, 0), + }, + headerContent: { + display: 'grid', + rowGap: tokens.spacingVerticalXXS, + minWidth: 0, + }, + headerTitle: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + headerMeta: { + color: tokens.colorNeutralForeground3, + }, + headerCount: { + color: tokens.colorNeutralForeground2, + }, + messageRow: { + display: 'grid', + gridTemplateAreas: ` + 'unread avatar title meta' + 'unread avatar location meta' + '. preview preview preview' + `, + gridTemplateColumns: '16px 44px minmax(0, 1fr) 176px', + gridTemplateRows: '20px 20px 24px', + alignItems: 'start', + columnGap: tokens.spacingHorizontalM, + rowGap: tokens.spacingVerticalXS, + backgroundColor: tokens.colorNeutralBackground2, + ...shorthands.padding(tokens.spacingVerticalM, tokens.spacingHorizontalL), + boxSizing: 'border-box', + width: '100%', + ':hover': { + '--threadedRevealVisibility': 'visible', + '--threadedRevealOpacity': '1', + '--threadedRevealPointerEvents': 'auto', + '--threadedTimestampRevealOpacity': '0', + '--threadedTimestampRevealVisibility': 'hidden', + backgroundColor: tokens.colorNeutralBackground4Selected, + }, + ':focus-within': { + '--threadedRevealVisibility': 'visible', + '--threadedRevealOpacity': '1', + '--threadedRevealPointerEvents': 'auto', + '--threadedTimestampRevealOpacity': '0', + '--threadedTimestampRevealVisibility': 'hidden', + }, + }, + threadOutlined: { + boxShadow: `inset 2px 0 0 0 ${tokens.colorStrokeFocus2}, inset -2px 0 0 0 ${tokens.colorStrokeFocus2}`, + }, + threadOutlinedLast: { + boxShadow: `inset 2px 0 0 0 ${tokens.colorStrokeFocus2}, inset -2px 0 0 0 ${tokens.colorStrokeFocus2}, inset 0 -2px 0 0 ${tokens.colorStrokeFocus2}`, + }, + unread: { + gridArea: 'unread', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + unreadDot: { + width: '8px', + height: '8px', + ...shorthands.borderRadius(tokens.borderRadiusCircular), + backgroundColor: tokens.colorPaletteRoyalBlueForeground2, + }, + avatar: { + gridArea: 'avatar', + }, + title: { + gridArea: 'title', + minWidth: 0, + }, + messageTitle: { + color: tokens.colorNeutralForeground1, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + location: { + gridArea: 'location', + color: tokens.colorNeutralForeground2, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + }, + preview: { + gridArea: 'preview', + color: tokens.colorNeutralForeground2, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + }, + previewLink: { + marginLeft: tokens.spacingHorizontalXS, + }, + meta: { + gridArea: 'meta', + width: '176px', + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'flex-start', + gap: tokens.spacingHorizontalS, + }, + timestamp: { + color: tokens.colorNeutralForeground3, + minWidth: '32px', + textAlign: 'right', + opacity: 'var(--threadedTimestampRevealOpacity, 1)', + visibility: 'var(--threadedTimestampRevealVisibility, visible)' as + | 'visible' + | 'hidden', + }, + actionButton: { + minWidth: 'unset', + opacity: 'var(--threadedRevealOpacity, 0)', + visibility: 'var(--threadedRevealVisibility, hidden)' as + | 'visible' + | 'hidden', + pointerEvents: 'var(--threadedRevealPointerEvents, none)' as + | 'auto' + | 'none', + }, + inputRow: { + display: 'grid', + gridTemplateColumns: '1fr', + gridTemplateRows: 'auto', + backgroundColor: tokens.colorNeutralBackground2, + ...shorthands.padding(tokens.spacingVerticalM, tokens.spacingHorizontalL), + boxSizing: 'border-box', + width: '100%', + }, + inputCell: { + width: '100%', + }, + inputInteraction: { + width: '100%', + }, + inputTextarea: { + width: '100%', + }, +}); + +const ThreadedVirtualizationContext = React.createContext< + ThreadedVirtualizationContextValue | undefined +>(undefined); + +const useThreadedVirtualizationContext = + (): ThreadedVirtualizationContextValue => { + const context = React.useContext(ThreadedVirtualizationContext); + if (!context) { + throw new Error( + 'useThreadedVirtualizationContext must be used within a provider' + ); + } + return context; + }; + +type TabsterMoveFocusEventDetail = { + owner: HTMLElement; + relatedEvent: Event; +}; + +const usePreventTabsterKeys = ( + ref: React.RefObject, + keys: string[], + rowTypeFilter?: string +): void => { + React.useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + + const listener = (event: CustomEvent) => { + const { owner, relatedEvent } = event.detail; + + if (owner !== element || !relatedEvent) { + return; + } + + const target = (relatedEvent as KeyboardEvent).target; + if (!isHTMLElement(target)) { + return; + } + + if (rowTypeFilter) { + const row = target.closest('[data-rowtype]'); + if (row?.dataset.rowtype !== rowTypeFilter) { + return; + } + } + + if (keys.includes((relatedEvent as KeyboardEvent).key)) { + event.preventDefault(); + } + }; + + element.addEventListener('tabster:movefocus', listener as EventListener); + + return () => { + element.removeEventListener( + 'tabster:movefocus', + listener as EventListener + ); + }; + }, [ref, keys, rowTypeFilter]); +}; + +const ThreadedVirtualizationRow = React.memo( + (props: ListChildComponentProps): React.ReactElement => { + const styles = useStyles(); + const item = props.data[props.index]; + const nextItem = props.data[props.index + 1]; + const context = useThreadedVirtualizationContext(); + const rowStyle: React.CSSProperties = { + ...props.style, + width: `calc(100% - ${rowFocusGap * 2}px)`, + marginInline: `${rowFocusGap}px`, + ...(item.type === 'thread-header' && props.index > 0 + ? { paddingTop: `${threadHeaderGap}px` } + : null), + }; + + if (item.type === 'thread-header') { + const open = context.openItems.get(item.value) !== undefined; + const outlined = context.focusedHeaderId === item.value; + const outlinedBottom = + outlined && (!nextItem || nextItem.type === 'thread-header'); + const headerRowRef = React.useRef(null); + const headerCellRef = React.useRef(null); + const headerTabsterAttributes = useTabsterAttributes({ + mover: { cyclic: false, direction: 2, memorizeCurrent: true }, + groupper: { tabbability: 2 }, + }); + const headerRowRefCallback = React.useCallback( + (element: HTMLDivElement | null) => { + headerRowRef.current = element; + context.registerElementRef(item.value, element); + }, + [context, item.value] + ); + + const onFocusCapture = React.useCallback( + (event: React.FocusEvent) => { + context.setFocusedHeaderId( + event.target === event.currentTarget ? item.value : undefined + ); + }, + [context, item.value] + ); + + const onBlurCapture = React.useCallback( + (event: React.FocusEvent) => { + const nextFocused = event.relatedTarget; + if ( + isHTMLElement(nextFocused) && + event.currentTarget.contains(nextFocused) + ) { + return; + } + + context.setFocusedHeaderId((prev) => + prev === item.value ? undefined : prev + ); + }, + [context, item.value] + ); + + const onHeaderKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + const isOnHeaderRow = + isHTMLElement(event.target) && event.target === event.currentTarget; + + if (event.key === 'r' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + context.focusInput(item.value); + return; + } + + if (!isOnHeaderRow) { + return; + } + + switch (event.key) { + case ' ': { + event.preventDefault(); + context.focusUnread(item.value); + return; + } + case ArrowDown: { + event.preventDefault(); + event.stopPropagation(); + context.focusNextHeader(item.value); + return; + } + case ArrowUp: { + event.preventDefault(); + event.stopPropagation(); + context.focusPrevHeader(item.value); + return; + } + case Home: { + event.preventDefault(); + event.stopPropagation(); + context.focusFirstItem(); + return; + } + case End: { + event.preventDefault(); + event.stopPropagation(); + context.focusLastItem(); + return; + } + case ArrowRight: { + if (open) { + event.preventDefault(); + event.stopPropagation(); + headerCellRef.current?.focus(); + } + return; + } + } + }, + [context, item.value, open] + ); + + const onHeaderButtonKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== ArrowLeft) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + headerRowRef.current?.focus(); + }, + [] + ); + + return ( + + context.requestOpenChange({ ...data, index: props.index }) + } + open={open} + ref={headerRowRefCallback} + style={rowStyle} + subtree + tabIndex={0} + {...headerTabsterAttributes} + > + {open ? ( + + ) : ( + + )} + +
+ + {item.header} + + + {item.messageCount} messages · updated {item.lastUpdated} + +
+
+ + {item.messageCount} + +
+ ); + } + + const threadId = item.parentValue; + const threadOutlined = context.focusedHeaderId === threadId; + const isLastInThread = + !nextItem || + nextItem.type === 'thread-header' || + nextItem.parentValue !== threadId; + + if (item.type === 'thread-input') { + const textareaRefCallback = React.useCallback( + (element: HTMLTextAreaElement | null) => { + context.registerElementRef(`${threadId}-input`, element); + }, + [context, threadId] + ); + + const inputOutlineClassName = threadOutlined + ? isLastInThread + ? styles.threadOutlinedLast + : styles.threadOutlined + : undefined; + + return ( + { + /* noop */ + }, + }} + > + + + +