Skip to content
5 changes: 5 additions & 0 deletions .changeset/actionlist-inactive-disabled-invalidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

ActionList: Reduce style recalculation when unrelated elements on the page change hover state or attributes
5 changes: 5 additions & 0 deletions .changeset/treeview-level-line-invalidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

TreeView: Reduce style recalculation when the pointer or focus enters or leaves the tree, and when focus moves between items in Safari
44 changes: 36 additions & 8 deletions packages/react/src/ActionList/ActionList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -267,19 +267,24 @@

/* inactive */
&:where([data-inactive='true']) {
/* ignore tooltip */
& * :not([popover], .InactiveWarning) {
color: var(--fgColor-muted);
}
/* text color is set by `.InactiveColors` below */

@media (hover: hover) {
&:hover {
cursor: not-allowed;
background-color: transparent;

& * :not([popover], .InactiveWarning) {
/* outrank the danger variant's hover colors */
& .LeadingAction,
& .LeadingVisual,
& .ItemLabel {
Comment thread
mattcosta7 marked this conversation as resolved.
Resolved
color: var(--fgColor-muted);
}

/* an `aria-disabled` prop without `disabled`: the inactive color wins while hovered */
&.DisabledColors {
--disabledItem-fgColor: var(--fgColor-muted);
}
}
}

Expand Down Expand Up @@ -316,6 +321,31 @@
}
}

/*
* Inactive and disabled items mute every descendant, including nested components that set
* their own color. `Item.tsx` sets `.InactiveColors` and `.DisabledColors` on the `<li>`.
*
* Key these universal descendant selectors on those classes, never on `:hover` or an
* attribute. Blink builds invalidation sets per feature for the whole page, so `:hover *` or
* `[aria-disabled] *` would restyle the entire subtree of any element, in any component,
* whose hover state or attribute changes.
*/

/* ignore tooltip */
&.InactiveColors * :not([popover], .InactiveWarning) {
color: var(--fgColor-muted);
}

&.DisabledColors .ActionListContent * {
/* stylelint-disable-next-line primer/colors -- private custom property, defaults to a Primer token */
color: var(--disabledItem-fgColor, var(--control-fgColor-disabled));
}

/* the tooltip and warning keep the disabled color, even while hovered */
&.DisabledColors .ActionListContent :is([popover], .InactiveWarning) {
--disabledItem-fgColor: var(--control-fgColor-disabled);
}

/* if item has subitem, move hover styles to ActionListContent */
&[data-has-subitem='true'] {
/* first child */
Expand Down Expand Up @@ -343,9 +373,7 @@

&[aria-disabled='true'],
&[data-is-disabled] {
& .ActionListContent * {
color: var(--control-fgColor-disabled);
}
/* text color is set by `.DisabledColors` above */

& .ActionListContent {
@media (hover: hover) {
Expand Down
69 changes: 69 additions & 0 deletions packages/react/src/ActionList/Item.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {ActionList} from '.'
import {AnchoredOverlay} from '../AnchoredOverlay'
import {BookIcon} from '@primer/octicons-react'
import {implementsClassName} from '../utils/testing'
import CounterLabel from '../CounterLabel'
import classes from './ActionList.module.css'

function SimpleActionList(): JSX.Element {
Expand Down Expand Up @@ -610,4 +611,72 @@ describe('ActionList.Item', () => {
expect(action).not.toHaveAttribute('data-has-label')
})
})

describe('inactive and disabled text color', () => {
function renderItem(props: React.ComponentProps<typeof ActionList.Item>) {
const {container} = HTMLRender(
<ActionList role="menu" aria-label="Menu">
<ActionList.Item {...props}>
Label
<ActionList.TrailingVisual>
<CounterLabel>3</CounterLabel>
</ActionList.TrailingVisual>
</ActionList.Item>
<ActionList.Item>
Enabled
<ActionList.TrailingVisual>
<CounterLabel>4</CounterLabel>
</ActionList.TrailingVisual>
</ActionList.Item>
</ActionList>,
)
const [item, enabled] = container.querySelectorAll<HTMLElement>('[data-component="ActionList.Item"]')
const colorOf = (el: Element | null) => getComputedStyle(el!).color
return {
label: colorOf(item.querySelector(`.${classes.ItemLabel}`)),
nested: colorOf(item.querySelector('[data-component="CounterLabel"]')),
enabledNested: colorOf(enabled.querySelector('[data-component="CounterLabel"]')),
warning: item.querySelector(`.${classes.InactiveWarning}`),
}
}

it('mutes nested components that set their own color in inactive items', () => {
const {label, nested, enabledNested, warning} = renderItem({inactiveText: 'Unavailable'})
expect(nested).toBe(label)
expect(nested).not.toBe(enabledNested)
expect(getComputedStyle(warning!).color).not.toBe(label)
})

it('mutes nested components that set their own color in disabled items', () => {
const {label, nested, enabledNested} = renderItem({disabled: true})
expect(nested).toBe(label)
expect(nested).not.toBe(enabledNested)
})

it('mutes every descendant while hovering an inactive item that also sets aria-disabled', async () => {
const {container} = HTMLRender(
<ActionList>
<ActionList.Item inactiveText="Unavailable" aria-disabled>
<ActionList.LeadingVisual>
<BookIcon />
</ActionList.LeadingVisual>
Label
<ActionList.TrailingVisual>
<CounterLabel>3</CounterLabel>
</ActionList.TrailingVisual>
</ActionList.Item>
<ActionList.Item inactiveText="Unavailable">Inactive</ActionList.Item>
</ActionList>,
)
const [item, inactiveOnly] = container.querySelectorAll<HTMLElement>('[data-component="ActionList.Item"]')
const colorOf = (el: Element | null) => getComputedStyle(el!).color
const mutedColor = colorOf(inactiveOnly.querySelector(`.${classes.ItemLabel}`))

await userEvent.hover(item.querySelector(`.${classes.ItemLabel}`)!)

await expect.poll(() => colorOf(item.querySelector(`.${classes.ItemLabel}`))).toBe(mutedColor)
expect(colorOf(item.querySelector('[data-component="CounterLabel"]'))).toBe(mutedColor)
expect(colorOf(item.querySelector(`.${classes.ActionListSubContent}`))).toBe(mutedColor)
})
})
})
13 changes: 12 additions & 1 deletion packages/react/src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,12 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
// attributes so the CSS only kicks in when the action is actually in the DOM.
const trailingActionRendered = !inactive && !loading && !menuContext && Boolean(slots.trailingAction)

// Mirrors the `<li>` attributes below so CSS can key descendant colors on classes instead of
// attributes. See `.InactiveColors` in ActionList.module.css.
const liAriaDisabled = 'aria-disabled' in containerProps ? containerProps['aria-disabled'] : undefined
const disabledColors = disabled || liAriaDisabled === true || liAriaDisabled === 'true'
const inactiveColors = Boolean(inactiveText) && !disabled && !slots.subItem

return (
<ItemContext.Provider value={itemContextValue}>
<li
Expand All @@ -332,7 +338,12 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
data-has-description={slots.description ? true : false}
data-has-trailing-action={trailingActionRendered ? true : undefined}
data-trailing-action-loading={trailingActionRendered && slots.trailingAction?.props.loading ? true : undefined}
className={clsx(classes.ActionListItem, className)}
className={clsx(
classes.ActionListItem,
inactiveColors && classes.InactiveColors,
disabledColors && classes.DisabledColors,
className,
)}
>
<ConditionalTooltip ref={forwardedRef} text={_PrivateTooltipText ?? truncatedText} enabled={buttonSemantics}>
<ItemWrapper
Expand Down
33 changes: 19 additions & 14 deletions packages/react/src/TreeView/TreeView.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -214,24 +214,29 @@
height: 100%;
border-right: var(--borderWidth-thin) solid;

/*
* `--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.
*/
/* 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 without hover, the nesting indicator lines are always visible. */
border-color: var(--borderColor-muted);
}

/*
* On devices with hover, the lines fade in while the tree is hovered or has focus.
*
* Keep this a class-targeted descendant rule. Toggling an inherited custom property on the
* root instead forces every element in the tree to restyle on each pointer enter/leave and
* focus in/out; this rule restyles only the `.TreeViewItemLevelLine` elements.
*
* Focus uses `data-focus-within` (set by `useFocusWithinAttribute`), not `:focus-within`:
* WebKit re-toggles `:focus-within` on every focus move inside the tree, which restyled every
* level line on each arrow key press.
*/
@media (hover: hover) {
--tree-line-color: transparent;
.TreeViewItemLevelLine {
border-color: transparent;
}

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

Expand Down
30 changes: 30 additions & 0 deletions packages/react/src/TreeView/TreeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,36 @@
})
})

describe('Focus within', () => {
it('sets data-focus-within on the root only while focus is inside the tree', async () => {
render(
<>
<button type="button">Before</button>
<TreeView aria-label="Test tree">
<TreeView.Item id="item-1">Item 1</TreeView.Item>
<TreeView.Item id="item-2">Item 2</TreeView.Item>
</TreeView>
<button type="button">After</button>
</>,
)
const root = page.getByRole('tree').element()

expect(root).not.toHaveAttribute('data-focus-within')

act(() => page.getByRole('button', {name: 'Before'}).element().focus())
await userEvent.keyboard('{Tab}')
expect(root).toHaveAttribute('data-focus-within')

await userEvent.keyboard('{ArrowDown}')
await expect.element(page.getByRole('treeitem', {name: 'Item 2'})).toHaveFocus()
expect(root).toHaveAttribute('data-focus-within')

await userEvent.keyboard('{Tab}')
await expect.element(page.getByRole('button', {name: 'After'})).toHaveFocus()
expect(root).not.toHaveAttribute('data-focus-within')
})
})

describe('Tab order', () => {
const getTabStops = (container: Element) =>
Array.from(container.querySelectorAll('[tabindex="0"]')).map(element => element.id || element.textContent)
Expand Down Expand Up @@ -1921,7 +1951,7 @@
await userEvent.keyboard('n')

// Cantalope should be focused
await expect.element(cantalope).toHaveFocus()

Check failure on line 1954 in packages/react/src/TreeView/TreeView.test.tsx

View workflow job for this annotation

GitHub Actions / test (react-19)

[@primer/react (chromium)] src/TreeView/TreeView.test.tsx > Keyboard interactions > Typeahead > supports multiple typed characters

Error: expect(element).toHaveFocus() Expected element with focus: <li aria-labelledby="_r_kl_" aria-level="1" aria-selected="false" class="PRIVATE_TreeView-item prc-TreeView-TreeViewItem-N28Gi" id="cantalope-1" role="treeitem" tabindex="-1" > <div class="PRIVATE_TreeView-item-container prc-TreeView-TreeViewItemContainer-ioc9j" style="--level: 1;" > <div style="grid-area: spacer; display: flex;" > <div style="width: 100%; display: flex;" /> </div> <div class="PRIVATE_TreeView-item-content prc-TreeView-TreeViewItemContent-2ZCFZ" id="_r_kl_" > <span class="PRIVATE_TreeView-item-content-text prc-TreeView-TreeViewItemContentText--EGq-" > Cantalope 1 </span> </div> </div> </li> Received element with focus: <li aria-labelledby="_r_k9_" aria-level="1" aria-selected="true" class="PRIVATE_TreeView-item prc-TreeView-TreeViewItem-N28Gi" id="apple" role="treeitem" tabindex="0" > <div class="PRIVATE_TreeView-item-container prc-TreeView-TreeViewItemContainer-ioc9j" style="--level: 1;" > <div style="grid-area: spacer; display: flex;" > <div style="width: 100%; display: flex;" /> </div> <div class="PRIVATE_TreeView-item-content prc-TreeView-TreeViewItemContent-2ZCFZ" id="_r_k9_" > <span class="PRIVATE_TreeView-item-content-text prc-TreeView-TreeViewItemContentText--EGq-" > Apple </span> </div> </div> </li> ❯ Proxy.__VITEST_POLL_CHAIN__ node_modules/.vite/vitest/88f7c8ff1f2df46543c35757ccb666e6432e21b0/deps/index.aTZaPVhf-CqphtdTo.js?v=6bbc1a06:5800:47 ❯ src/TreeView/TreeView.test.tsx:1954:39 ❯ node_modules/.vite/vitest/88f7c8ff1f2df46543c35757ccb666e6432e21b0/deps/plugins.DPQHVT0p-CqKEm0WF.js?v=6bbc1a06:5242:20 Caused by: Caused by: Error: Matcher did not succeed in time. ❯ throwWithCause node_modules/.vite/vitest/88f7c8ff1f2df46543c35757ccb666e6432e21b0/deps/index.aTZaPVhf-CqphtdTo.js?v=6bbc1a06:5780:34 ❯ promise node_modules/.vite/vitest/88f7c8ff1f2df46543c35757ccb666e6432e21b0/deps/index.aTZaPVhf-CqphtdTo.js?v=6bbc1a06:5865:7
})

it('prioritizes items following the current aria-activedescendant', async () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/TreeView/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {useSlots} from '../hooks/useSlots'
import {getAccessibleName} from './shared'
import {getFirstChildElement} from './useRovingTabIndex'
import {useTreeRovingTabIndex} from './useTreeRovingTabIndex'
import {useFocusWithinAttribute} from './useFocusWithinAttribute'
import {useTypeahead} from './useTypeahead'
import {SkeletonAvatar} from '../SkeletonAvatar'
import {SkeletonText} from '../SkeletonText'
Expand Down Expand Up @@ -141,6 +142,7 @@ const Root: React.FC<TreeViewProps> = ({
}, [])

useTreeRovingTabIndex({containerRef, mouseDownRef})
useFocusWithinAttribute(containerRef)
useTypeahead({
containerRef,
onFocusChange: element => {
Expand Down
33 changes: 33 additions & 0 deletions packages/react/src/TreeView/useFocusWithinAttribute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type React from 'react'
import {useEffect} from 'react'

/**
* Mirrors `:focus-within` onto the tree root as `data-focus-within`.
*
* WebKit clears and re-sets `:focus-within` up the ancestor chain on every focus move, so a
* `:focus-within` rule on the root restyles every level line on each arrow key press. This
* attribute only changes when focus enters or leaves the tree. It is written to the DOM
* directly so the tree doesn't re-render.
*/
export function useFocusWithinAttribute(containerRef: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const root = containerRef.current
if (!root) return

const sync = (event?: FocusEvent) => {
const focused = event
? event.type === 'focusin' || root.contains(event.relatedTarget as Node | null)
: root.matches(':focus-within')
root.toggleAttribute('data-focus-within', focused)
}

sync()
root.addEventListener('focusin', sync)
root.addEventListener('focusout', sync)
return () => {
root.removeEventListener('focusin', sync)
root.removeEventListener('focusout', sync)
root.removeAttribute('data-focus-within')
}
}, [containerRef])
}
Loading