Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/treeview-indicator-paint-safe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@primer/react': patch
---

`TreeView`: make rows safer to use with `contain: paint` / `content-visibility: auto` and reduce style-recalc cost on hover/focus in large trees. No visual or layout changes; all changes are either invisible at the default rendering or behind an opt-in CSS containment property the consumer sets.

- The current-item indicator (positioned at `left: -8px` of the row container) was being clipped when a consumer applied `contain: paint` to the `<li>` or when the documented `containIntrinsicSize` prop on `TreeView.Item` triggered `content-visibility: auto` on the row container — including for `current` items. Both `.TreeViewItem` and `.TreeViewItemContainer` now declare `overflow-clip-margin: var(--base-size-8)`, which extends the paint-clip edge by 8px on the side the indicator paints. The property is a no-op when no paint containment is active, so default rendering is byte-identical.
- Skeleton-row hover suppression no longer relies on `:has(.TreeViewItemSkeleton)`, which forced subtree invalidation on every row. `LoadingItem` now communicates with the placeholder `Item` via a module-private context that emits a positive `data-loading` attribute on the `<li>`, and the CSS selector targets that directly. No new public prop.
- Nesting indicator lines no longer use a root-scope `:hover`/`:focus-within` descendant selector. Color is driven by an inherited `--tree-line-color` custom property set on the root `<ul>`, so a hover or focus change inside the tree updates one property on one element instead of re-matching `.TreeViewItemLevelLine` selectors against every level line in the tree.
- Fixed a unitless `outline-offset: -2` in the forced-colors focus-ring fallback that browsers were silently dropping (so forced-colors users now actually get a focus indicator on tree items).
- `.TreeViewItemContainer`'s `grid-template-columns` now declares the `trailingAction` column explicitly (`auto`) so it matches the 5-area `grid-template-areas` declaration (previously the trailing column was implicit `auto`).
27 changes: 27 additions & 0 deletions packages/react/src/TreeView/TreeView.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,33 @@ export const ContainIntrinsicSize: StoryFn = () => {
)
}

// `containIntrinsicSize` sets `content-visibility: auto` on the row container, which implies
// `contain: paint`. The current-item indicator must paint inside the row's box or it gets clipped.
export const CurrentItemWithContainIntrinsicSize: StoryFn = () => {
return (
<TreeView aria-label="Files">
<TreeView.Item id="file-1" containIntrinsicSize="2rem">
<TreeView.LeadingVisual>
<FileIcon />
</TreeView.LeadingVisual>
File 1
</TreeView.Item>
<TreeView.Item id="file-2" containIntrinsicSize="2rem" current>
<TreeView.LeadingVisual>
<FileIcon />
</TreeView.LeadingVisual>
File 2
</TreeView.Item>
<TreeView.Item id="file-3" containIntrinsicSize="2rem">
<TreeView.LeadingVisual>
<FileIcon />
</TreeView.LeadingVisual>
File 3
</TreeView.Item>
</TreeView>
)
}

export const InitialFocus: StoryFn = () => (
<div>
<Button>Focusable element before TreeView</Button>
Expand Down
78 changes: 43 additions & 35 deletions packages/react/src/TreeView/TreeView.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@
* Do NOT copy this pattern without understanding the tradeoffs.
*/
.TreeViewItem {
/*
* `overflow-clip-margin` extends the paint clip edge by 8px so the current-item indicator
* (positioned at `left: -8px` of the row container) remains visible when a consumer applies
* `contain: paint` (or `contain: strict`, or `content-visibility: auto`) to this `<li>`. Has
* no effect when no paint containment is active, so default rendering is unchanged.
*/
overflow-clip-margin: var(--base-size-8);
outline: none;

&:focus-visible > div,
Expand All @@ -25,8 +32,7 @@

@media (forced-colors: active) {
outline: 2px solid HighlightText;
/* stylelint-disable-next-line declaration-property-value-no-unknown */
outline-offset: -2;
outline-offset: -2px;
}
}

Expand All @@ -43,11 +49,17 @@
position: relative;
display: grid;
width: 100%;
/*
* Mirrors the `overflow-clip-margin` on `.TreeViewItem` so the indicator also stays
* visible when `containIntrinsicSize` is set on this row (which sets
* `content-visibility: auto` on this container and implies paint containment).
*/
overflow-clip-margin: var(--base-size-8);
font-size: var(--text-body-size-medium);
color: var(--fgColor-default);
cursor: pointer;
border-radius: var(--borderRadius-medium);
grid-template-columns: var(--spacer-width) var(--leading-action-width) var(--toggle-width) 1fr;
grid-template-columns: var(--spacer-width) var(--leading-action-width) var(--toggle-width) 1fr auto;
grid-template-areas: 'spacer leadingAction toggle content trailingAction';

--leading-action-width: calc(var(--has-leading-action, 0) * 1.5rem);
Expand All @@ -66,25 +78,24 @@
--toggle-width: 1.5rem;
--min-item-height: 2.75rem;
}

/*
* NOTE: Uses descendant :has() - TreeViewItemSkeleton is nested inside
* TreeViewItemContent > TreeViewItemContentText, not a direct child.
* This is acceptable as the search is scoped to this element's subtree.
*/
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has(.TreeViewItemSkeleton):hover {
cursor: default;
background-color: transparent;

@media (forced-colors: active) {
outline: none;
}
}
}

&:where([data-omit-spacer='true']) .TreeViewItemContainer {
grid-template-columns: 0 0 0 1fr;
grid-template-columns: 0 0 0 1fr auto;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auto is the default, but it reads nicer imp when it's 5 columns defined for 5 columns

}

/*
* Suppress hover affordances on rows being used as skeleton loading placeholders.
* Marked positively via `data-loading` from `LoadingItem` so we avoid the broad
* invalidation cost of `:has(.TreeViewItemSkeleton)` across every row in large trees.
*/
.TreeViewItem:where([data-loading]) > .TreeViewItemContainer:hover {
cursor: default;
background-color: transparent;

@media (forced-colors: active) {
outline: none;
}
}

.TreeViewItem[aria-current='true'] > .TreeViewItemContainer {
Expand Down Expand Up @@ -201,29 +212,26 @@
.TreeViewItemLevelLine {
width: 100%;
height: 100%;
border-right: var(--borderWidth-thin) solid;

/*
* On devices without hover, the nesting indicator lines
* appear at all times.
* `--tree-line-color` is set on the root `<ul>` and inherited down. On coarse pointers it
* stays unset and falls back to `muted` (lines always visible). On hover-capable devices it
* is initialized to `transparent` on the root and flipped to `muted` while the tree is
* hovered or focused, so the browser only has to propagate a single inherited custom
* property instead of re-matching `.TreeViewItemLevelLine` descendant selectors on every
* hover/focus change inside large trees.
*/
border-color: var(--borderColor-muted);
border-right: var(--borderWidth-thin) solid;
/* stylelint-disable-next-line primer/colors -- private custom property, defaults to a Primer token */
border-color: var(--tree-line-color, var(--borderColor-muted));
}

/*
* On devices with :hover support, the nesting indicator lines
* fade in when the user mouses over the entire component,
* or when there's focus inside the component. This makes
* sure the component remains simple when not in use.
*/
@media (hover: hover) {
.TreeViewItemLevelLine {
border-color: transparent;
}
--tree-line-color: transparent;

&:hover .TreeViewItemLevelLine,
&:focus-within .TreeViewItemLevelLine {
border-color: var(--borderColor-muted);
&:hover,
&:focus-within {
--tree-line-color: var(--borderColor-muted);
}
}

Expand Down
35 changes: 23 additions & 12 deletions packages/react/src/TreeView/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ const ItemContext = React.createContext<{
trailingActionId: '',
})

// Module-private channel from LoadingItem to the Item it renders. Lets us mark the placeholder
// row with `data-loading` (used by CSS to suppress hover affordances) without exposing an
// internal-only prop on the public TreeViewItemProps API.
const LoadingPlaceholderContext = React.createContext(false)

// ----------------------------------------------------------------------------
// TreeView

Expand Down Expand Up @@ -255,6 +260,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
onChange: onExpandedChange,
})
const {level} = React.useContext(ItemContext)
const isLoadingPlaceholder = React.useContext(LoadingPlaceholderContext)
const {hasSubTree, subTree, childrenWithoutSubTree} = useSubTree(rest)
const [isSubTreeEmpty, setIsSubTreeEmpty] = React.useState(!hasSubTree)
const [actionCommandPressed, setActionCommandPressed] = React.useState(false)
Expand Down Expand Up @@ -371,6 +377,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
aria-current={isCurrentItem ? 'true' : undefined}
aria-selected={isFocused ? 'true' : 'false'}
data-has-leading-action={slots.leadingAction ? true : undefined}
data-loading={isLoadingPlaceholder ? true : undefined}
onKeyDown={handleKeyDown}
onFocus={event => {
// Defer scroll to the next animation frame so that rapid keyboard
Expand Down Expand Up @@ -645,22 +652,26 @@ const LoadingItem = React.forwardRef<HTMLElement, LoadingItemProps>(({count}, re

if (count) {
return (
<Item id={itemId} ref={ref}>
{Array.from({length: count}).map((_, i) => {
return <SkeletonItem aria-hidden={true} key={i} />
})}
<div className={clsx('PRIVATE_VisuallyHidden', classes.TreeViewVisuallyHidden)}>Loading {count} items</div>
</Item>
<LoadingPlaceholderContext.Provider value={true}>
<Item id={itemId} ref={ref}>
{Array.from({length: count}).map((_, i) => {
return <SkeletonItem aria-hidden={true} key={i} />
})}
<div className={clsx('PRIVATE_VisuallyHidden', classes.TreeViewVisuallyHidden)}>Loading {count} items</div>
</Item>
</LoadingPlaceholderContext.Provider>
)
}

return (
<Item id={itemId} ref={ref}>
<LeadingVisual>
<Spinner size="small" />
</LeadingVisual>
<Text className="fgColor-muted">Loading...</Text>
</Item>
<LoadingPlaceholderContext.Provider value={true}>
<Item id={itemId} ref={ref}>
<LeadingVisual>
<Spinner size="small" />
</LeadingVisual>
<Text className="fgColor-muted">Loading...</Text>
</Item>
</LoadingPlaceholderContext.Provider>
)
})

Expand Down
Loading