diff --git a/.changeset/swift-kiwis-sparkle.md b/.changeset/swift-kiwis-sparkle.md new file mode 100644 index 00000000000..d4d0dbe4ed0 --- /dev/null +++ b/.changeset/swift-kiwis-sparkle.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +TreeView: Add containIntrinsicSize prop and typeahead performance improvement diff --git a/docs/content/TreeView.mdx b/docs/content/TreeView.mdx index b319a7b7a98..01ce9c2aebe 100644 --- a/docs/content/TreeView.mdx +++ b/docs/content/TreeView.mdx @@ -300,6 +300,11 @@ See [Storybook](https://primer.style/react/storybook?path=/story/components-tree type="boolean" description="The expanded state of the item when it is initially rendered. Use when you do not need to control the state." /> + { Directory {i} {Array.from({length: 100}).map((_, j) => ( - + @@ -670,4 +670,33 @@ StressTest.parameters = { chromatic: {disableSnapshot: true}, } +export const ContainIntrinsicSize: Story = () => { + return ( + + {Array.from({length: 10}).map((_, i) => ( + + + + + Directory {i} + + {Array.from({length: 1000}).map((_, j) => ( + + + + + File {j} + + ))} + + + ))} + + ) +} + +ContainIntrinsicSize.parameters = { + chromatic: {disableSnapshot: true}, +} + export default meta diff --git a/src/TreeView/TreeView.test.tsx b/src/TreeView/TreeView.test.tsx index 2ddc99e34be..03c77138768 100644 --- a/src/TreeView/TreeView.test.tsx +++ b/src/TreeView/TreeView.test.tsx @@ -198,6 +198,28 @@ describe('Markup', () => { await user.click(getByText(/Item 2/)) expect(treeitem).not.toHaveAttribute('aria-expanded') }) + + it('should render with containIntrinsicSize', () => { + const {getByLabelText} = renderWithTheme( + + + Parent + + + Child + + + + , + ) + + // The test runner removes the contain-intrinsic-size and content-visibility + // properties, so we can only test that the elements are still rendering. + const childItem = getByLabelText(/Child/) + expect(childItem).toBeInTheDocument() + const parentItem = getByLabelText(/Parent/) + expect(parentItem).toBeInTheDocument() + }) }) describe('Keyboard interactions', () => { diff --git a/src/TreeView/TreeView.tsx b/src/TreeView/TreeView.tsx index 4f0a10c8d87..21f2660f3b2 100644 --- a/src/TreeView/TreeView.tsx +++ b/src/TreeView/TreeView.tsx @@ -86,7 +86,8 @@ const UlBox = styled.ul` .PRIVATE_TreeView-item { outline: none; - &:focus-visible > div { + &:focus-visible > div, + &.focus-visible > div { box-shadow: inset 0 0 0 2px ${get(`colors.accent.fg`)}; @media (forced-colors: active) { outline: 2px solid HighlightText; @@ -293,6 +294,7 @@ Root.displayName = 'TreeView' export type TreeViewItemProps = { id: string children: React.ReactNode + containIntrinsicSize?: string current?: boolean defaultExpanded?: boolean expanded?: boolean @@ -304,7 +306,16 @@ const {Slots, Slot} = createSlots(['LeadingVisual', 'TrailingVisual']) const Item = React.forwardRef( ( - {id: itemId, current: isCurrentItem = false, defaultExpanded, expanded, onExpandedChange, onSelect, children}, + { + id: itemId, + containIntrinsicSize, + current: isCurrentItem = false, + defaultExpanded, + expanded, + onExpandedChange, + onSelect, + children, + }, ref, ) => { const {expandedStateCache} = React.useContext(RootContext) @@ -408,6 +419,8 @@ const Item = React.forwardRef( style={{ // @ts-ignore CSS custom property '--level': level, + contentVisibility: containIntrinsicSize ? 'auto' : undefined, + containIntrinsicSize, }} onClick={event => { if (onSelect) { diff --git a/src/TreeView/useTypeahead.ts b/src/TreeView/useTypeahead.ts index 02886db2631..fe430c597a3 100644 --- a/src/TreeView/useTypeahead.ts +++ b/src/TreeView/useTypeahead.ts @@ -8,7 +8,7 @@ type TypeaheadOptions = { } export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) { - const [searchValue, setSearchValue] = React.useState('') + const searchValue = React.useRef('') const timeoutRef = React.useRef(0) const onFocusChangeRef = React.useRef(onFocusChange) const {safeSetTimeout, safeClearTimeout} = useSafeTimeout() @@ -18,6 +18,44 @@ export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) { onFocusChangeRef.current = onFocusChange }, [onFocusChange]) + // Focus the closest element that matches the search value + const focusSearchValue = React.useCallback( + (searchValue: string) => { + // Don't change focus if the search value is empty + if (!searchValue) return + + if (!containerRef.current) return + const container = containerRef.current + + // Get focusable elements + const elements = Array.from(container.querySelectorAll('[role="treeitem"]')) + + // Get the index of active element + const activeIndex = elements.findIndex(element => element === document.activeElement) + + // Wrap the array elements such that the active descendant is at the beginning + let sortedElements = wrapArray(elements, activeIndex) + + // Remove the active descendant from the beginning of the array + // when the user initiates a new search + if (searchValue.length === 1) { + sortedElements = sortedElements.slice(1) + } + + // Find the first element that matches the search value + const nextElement = sortedElements.find(element => { + const name = getAccessibleName(element).toLowerCase() + return name.startsWith(searchValue.toLowerCase()) + }) + + // If a match is found, focus it + if (nextElement) { + onFocusChangeRef.current(nextElement) + } + }, + [containerRef], + ) + // Update the search value when the user types React.useEffect(() => { if (!containerRef.current) return @@ -31,11 +69,12 @@ export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) { if (event.ctrlKey || event.altKey || event.metaKey) return // Update the existing search value with the new key press - setSearchValue(value => value + event.key) + searchValue.current += event.key + focusSearchValue(searchValue.current) // Reset the timeout safeClearTimeout(timeoutRef.current) - timeoutRef.current = safeSetTimeout(() => setSearchValue(''), 300) + timeoutRef.current = safeSetTimeout(() => (searchValue.current = ''), 300) // Prevent default behavior event.preventDefault() @@ -44,44 +83,7 @@ export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) { container.addEventListener('keydown', onKeyDown) return () => container.removeEventListener('keydown', onKeyDown) - }, [containerRef, safeClearTimeout, safeSetTimeout]) - - // Update focus when the search value changes - React.useEffect(() => { - // Don't change focus if the search value is empty - if (!searchValue) return - - if (!containerRef.current) return - const container = containerRef.current - - // Get focusable elements - const elements = Array.from(container.querySelectorAll('[role="treeitem"]')) - // Filter out collapsed items - .filter(element => !element.parentElement?.closest('[role=treeitem][aria-expanded=false]')) - - // Get the index of active element - const activeIndex = elements.findIndex(element => element === document.activeElement) - - // Wrap the array elements such that the active descendant is at the beginning - let sortedElements = wrapArray(elements, activeIndex) - - // Remove the active descendant from the beginning of the array - // when the user initiates a new search - if (searchValue.length === 1) { - sortedElements = sortedElements.slice(1) - } - - // Find the first element that matches the search value - const nextElement = sortedElements.find(element => { - const name = getAccessibleName(element).toLowerCase() - return name.startsWith(searchValue.toLowerCase()) - }) - - // If a match is found, focus it - if (nextElement) { - onFocusChangeRef.current(nextElement) - } - }, [searchValue, containerRef]) + }, [containerRef, focusSearchValue, safeClearTimeout, safeSetTimeout]) } /**