Skip to content

Commit a8f2289

Browse files
jdrush89joshblack
andauthored
Jdrush89/tree content visibility (#2640)
* Using content-visibility: auto on tree items * Improving typeahead perf * Add prop docs * Updating focus style * Adding changeset * Fixing linter errors * removing filter for typeahead Co-authored-by: Josh Black <[email protected]>
1 parent ac38cb2 commit a8f2289

File tree

6 files changed

+120
-44
lines changed

6 files changed

+120
-44
lines changed

.changeset/swift-kiwis-sparkle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': patch
3+
---
4+
5+
TreeView: Add containIntrinsicSize prop and typeahead performance improvement

docs/content/TreeView.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,11 @@ See [Storybook](https://primer.style/react/storybook?path=/story/components-tree
300300
type="boolean"
301301
description="The expanded state of the item when it is initially rendered. Use when you do not need to control the state."
302302
/>
303+
<PropsTableRow
304+
name="containIntrinsicSize"
305+
type="string"
306+
description="The size of this item's contents. Passing this will set 'content-visiblity: auto' on the content container, delaying rendering until the item is in the viewport."
307+
/>
303308
<PropsTableRow
304309
name="expanded"
305310
type="boolean"

src/TreeView/TreeView.features.stories.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,7 @@ export const StressTest: Story = () => {
652652
Directory {i}
653653
<TreeView.SubTree>
654654
{Array.from({length: 100}).map((_, j) => (
655-
<TreeView.Item key={i} id={`directory-${i}/file-${j}`}>
655+
<TreeView.Item key={j} id={`directory-${i}/file-${j}`}>
656656
<TreeView.LeadingVisual>
657657
<FileIcon />
658658
</TreeView.LeadingVisual>
@@ -670,4 +670,33 @@ StressTest.parameters = {
670670
chromatic: {disableSnapshot: true},
671671
}
672672

673+
export const ContainIntrinsicSize: Story = () => {
674+
return (
675+
<TreeView aria-label="Files">
676+
{Array.from({length: 10}).map((_, i) => (
677+
<TreeView.Item key={i} id={`directory-${i}`} defaultExpanded containIntrinsicSize="2rem">
678+
<TreeView.LeadingVisual>
679+
<TreeView.DirectoryIcon />
680+
</TreeView.LeadingVisual>
681+
Directory {i}
682+
<TreeView.SubTree>
683+
{Array.from({length: 1000}).map((_, j) => (
684+
<TreeView.Item key={j} id={`directory-${i}/file-${j}`} containIntrinsicSize="2rem">
685+
<TreeView.LeadingVisual>
686+
<FileIcon />
687+
</TreeView.LeadingVisual>
688+
File {j}
689+
</TreeView.Item>
690+
))}
691+
</TreeView.SubTree>
692+
</TreeView.Item>
693+
))}
694+
</TreeView>
695+
)
696+
}
697+
698+
ContainIntrinsicSize.parameters = {
699+
chromatic: {disableSnapshot: true},
700+
}
701+
673702
export default meta

src/TreeView/TreeView.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,28 @@ describe('Markup', () => {
198198
await user.click(getByText(/Item 2/))
199199
expect(treeitem).not.toHaveAttribute('aria-expanded')
200200
})
201+
202+
it('should render with containIntrinsicSize', () => {
203+
const {getByLabelText} = renderWithTheme(
204+
<TreeView aria-label="Test tree">
205+
<TreeView.Item id="parent" containIntrinsicSize="2rem" defaultExpanded>
206+
Parent
207+
<TreeView.SubTree>
208+
<TreeView.Item containIntrinsicSize="2rem" id="child">
209+
Child
210+
</TreeView.Item>
211+
</TreeView.SubTree>
212+
</TreeView.Item>
213+
</TreeView>,
214+
)
215+
216+
// The test runner removes the contain-intrinsic-size and content-visibility
217+
// properties, so we can only test that the elements are still rendering.
218+
const childItem = getByLabelText(/Child/)
219+
expect(childItem).toBeInTheDocument()
220+
const parentItem = getByLabelText(/Parent/)
221+
expect(parentItem).toBeInTheDocument()
222+
})
201223
})
202224

203225
describe('Keyboard interactions', () => {

src/TreeView/TreeView.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ const UlBox = styled.ul<SxProp>`
8686
.PRIVATE_TreeView-item {
8787
outline: none;
8888
89-
&:focus-visible > div {
89+
&:focus-visible > div,
90+
&.focus-visible > div {
9091
box-shadow: inset 0 0 0 2px ${get(`colors.accent.fg`)};
9192
@media (forced-colors: active) {
9293
outline: 2px solid HighlightText;
@@ -293,6 +294,7 @@ Root.displayName = 'TreeView'
293294
export type TreeViewItemProps = {
294295
id: string
295296
children: React.ReactNode
297+
containIntrinsicSize?: string
296298
current?: boolean
297299
defaultExpanded?: boolean
298300
expanded?: boolean
@@ -304,7 +306,16 @@ const {Slots, Slot} = createSlots(['LeadingVisual', 'TrailingVisual'])
304306

305307
const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
306308
(
307-
{id: itemId, current: isCurrentItem = false, defaultExpanded, expanded, onExpandedChange, onSelect, children},
309+
{
310+
id: itemId,
311+
containIntrinsicSize,
312+
current: isCurrentItem = false,
313+
defaultExpanded,
314+
expanded,
315+
onExpandedChange,
316+
onSelect,
317+
children,
318+
},
308319
ref,
309320
) => {
310321
const {expandedStateCache} = React.useContext(RootContext)
@@ -408,6 +419,8 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
408419
style={{
409420
// @ts-ignore CSS custom property
410421
'--level': level,
422+
contentVisibility: containIntrinsicSize ? 'auto' : undefined,
423+
containIntrinsicSize,
411424
}}
412425
onClick={event => {
413426
if (onSelect) {

src/TreeView/useTypeahead.ts

Lines changed: 43 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ type TypeaheadOptions = {
88
}
99

1010
export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) {
11-
const [searchValue, setSearchValue] = React.useState('')
11+
const searchValue = React.useRef('')
1212
const timeoutRef = React.useRef(0)
1313
const onFocusChangeRef = React.useRef(onFocusChange)
1414
const {safeSetTimeout, safeClearTimeout} = useSafeTimeout()
@@ -18,6 +18,44 @@ export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) {
1818
onFocusChangeRef.current = onFocusChange
1919
}, [onFocusChange])
2020

21+
// Focus the closest element that matches the search value
22+
const focusSearchValue = React.useCallback(
23+
(searchValue: string) => {
24+
// Don't change focus if the search value is empty
25+
if (!searchValue) return
26+
27+
if (!containerRef.current) return
28+
const container = containerRef.current
29+
30+
// Get focusable elements
31+
const elements = Array.from(container.querySelectorAll('[role="treeitem"]'))
32+
33+
// Get the index of active element
34+
const activeIndex = elements.findIndex(element => element === document.activeElement)
35+
36+
// Wrap the array elements such that the active descendant is at the beginning
37+
let sortedElements = wrapArray(elements, activeIndex)
38+
39+
// Remove the active descendant from the beginning of the array
40+
// when the user initiates a new search
41+
if (searchValue.length === 1) {
42+
sortedElements = sortedElements.slice(1)
43+
}
44+
45+
// Find the first element that matches the search value
46+
const nextElement = sortedElements.find(element => {
47+
const name = getAccessibleName(element).toLowerCase()
48+
return name.startsWith(searchValue.toLowerCase())
49+
})
50+
51+
// If a match is found, focus it
52+
if (nextElement) {
53+
onFocusChangeRef.current(nextElement)
54+
}
55+
},
56+
[containerRef],
57+
)
58+
2159
// Update the search value when the user types
2260
React.useEffect(() => {
2361
if (!containerRef.current) return
@@ -31,11 +69,12 @@ export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) {
3169
if (event.ctrlKey || event.altKey || event.metaKey) return
3270

3371
// Update the existing search value with the new key press
34-
setSearchValue(value => value + event.key)
72+
searchValue.current += event.key
73+
focusSearchValue(searchValue.current)
3574

3675
// Reset the timeout
3776
safeClearTimeout(timeoutRef.current)
38-
timeoutRef.current = safeSetTimeout(() => setSearchValue(''), 300)
77+
timeoutRef.current = safeSetTimeout(() => (searchValue.current = ''), 300)
3978

4079
// Prevent default behavior
4180
event.preventDefault()
@@ -44,44 +83,7 @@ export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) {
4483

4584
container.addEventListener('keydown', onKeyDown)
4685
return () => container.removeEventListener('keydown', onKeyDown)
47-
}, [containerRef, safeClearTimeout, safeSetTimeout])
48-
49-
// Update focus when the search value changes
50-
React.useEffect(() => {
51-
// Don't change focus if the search value is empty
52-
if (!searchValue) return
53-
54-
if (!containerRef.current) return
55-
const container = containerRef.current
56-
57-
// Get focusable elements
58-
const elements = Array.from(container.querySelectorAll('[role="treeitem"]'))
59-
// Filter out collapsed items
60-
.filter(element => !element.parentElement?.closest('[role=treeitem][aria-expanded=false]'))
61-
62-
// Get the index of active element
63-
const activeIndex = elements.findIndex(element => element === document.activeElement)
64-
65-
// Wrap the array elements such that the active descendant is at the beginning
66-
let sortedElements = wrapArray(elements, activeIndex)
67-
68-
// Remove the active descendant from the beginning of the array
69-
// when the user initiates a new search
70-
if (searchValue.length === 1) {
71-
sortedElements = sortedElements.slice(1)
72-
}
73-
74-
// Find the first element that matches the search value
75-
const nextElement = sortedElements.find(element => {
76-
const name = getAccessibleName(element).toLowerCase()
77-
return name.startsWith(searchValue.toLowerCase())
78-
})
79-
80-
// If a match is found, focus it
81-
if (nextElement) {
82-
onFocusChangeRef.current(nextElement)
83-
}
84-
}, [searchValue, containerRef])
86+
}, [containerRef, focusSearchValue, safeClearTimeout, safeSetTimeout])
8587
}
8688

8789
/**

0 commit comments

Comments
 (0)