diff --git a/.changeset/treeview-indicator-paint-safe.md b/.changeset/treeview-indicator-paint-safe.md
new file mode 100644
index 00000000000..269c75c1a0b
--- /dev/null
+++ b/.changeset/treeview-indicator-paint-safe.md
@@ -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 `
` 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 `
`, 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 `
`, 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`).
diff --git a/packages/react/src/TreeView/TreeView.features.stories.tsx b/packages/react/src/TreeView/TreeView.features.stories.tsx
index ff027a604a0..f3b390d54a4 100644
--- a/packages/react/src/TreeView/TreeView.features.stories.tsx
+++ b/packages/react/src/TreeView/TreeView.features.stories.tsx
@@ -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 (
+
+
+
+
+
+ File 1
+
+
+
+
+
+ File 2
+
+
+
+
+
+ File 3
+
+
+ )
+}
+
export const InitialFocus: StoryFn = () => (
diff --git a/packages/react/src/TreeView/TreeView.module.css b/packages/react/src/TreeView/TreeView.module.css
index 57e3da65ff8..2c3ea4ee472 100644
--- a/packages/react/src/TreeView/TreeView.module.css
+++ b/packages/react/src/TreeView/TreeView.module.css
@@ -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 `
`. 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,
@@ -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;
}
}
@@ -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);
@@ -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;
+ }
+
+ /*
+ * 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 {
@@ -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 `
` 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);
}
}
diff --git a/packages/react/src/TreeView/TreeView.tsx b/packages/react/src/TreeView/TreeView.tsx
index fc41913a4c1..6c5bc6af2fb 100644
--- a/packages/react/src/TreeView/TreeView.tsx
+++ b/packages/react/src/TreeView/TreeView.tsx
@@ -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
@@ -255,6 +260,7 @@ const Item = React.forwardRef(
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)
@@ -371,6 +377,7 @@ const Item = React.forwardRef(
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
@@ -645,22 +652,26 @@ const LoadingItem = React.forwardRef(({count}, re
if (count) {
return (
-
- {Array.from({length: count}).map((_, i) => {
- return
- })}
-