diff --git a/.changeset/perf-selectpanel2-empty-input.md b/.changeset/perf-selectpanel2-empty-input.md
new file mode 100644
index 00000000000..0c470aa1097
--- /dev/null
+++ b/.changeset/perf-selectpanel2-empty-input.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': patch
+---
+
+SelectPanel (experimental v2): replace the `:has(input:placeholder-shown)` selector that hid the clear-action button with a `data-empty` attribute derived from React state. The clear-action visibility no longer triggers a descendant `:has()` re-evaluation on every keystroke.
diff --git a/packages/react/src/experimental/SelectPanel2/SelectPanel.module.css b/packages/react/src/experimental/SelectPanel2/SelectPanel.module.css
index 16afc9dd10a..536df6ca50f 100644
--- a/packages/react/src/experimental/SelectPanel2/SelectPanel.module.css
+++ b/packages/react/src/experimental/SelectPanel2/SelectPanel.module.css
@@ -121,8 +121,14 @@
.TextInput {
padding-left: var(--base-size-8) !important;
- /* stylelint-disable-next-line selector-class-pattern, selector-no-qualifying-type, selector-pseudo-class-disallowed-list -- :has() scoped to CSS Module, audited (github/github-ui#17224) */
- &:has(input:placeholder-shown) :global(.TextInput-action) {
+ /*
+ * `data-empty` lives on the inner `` (TextInput spreads unknown
+ * props onto the input, not its wrapper). The clear-action button is a
+ * later sibling of the input within this wrapper, so a general-sibling
+ * combinator targets it directly without a `:has()` walk.
+ */
+ /* stylelint-disable-next-line selector-class-pattern, selector-no-qualifying-type -- `.TextInput-action` is a third-party class on the wrapped TextInput */
+ & input[data-empty] ~ :global(.TextInput-action) {
display: none;
}
}
diff --git a/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx b/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx
index cc9efdea44a..ce6ac54d31d 100644
--- a/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx
+++ b/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx
@@ -430,11 +430,18 @@ const SelectPanelSearchInput: FCWithSlotMarker = ({
const {setSearchQuery, moveFocusToList} = React.useContext(SelectPanelContext)
+ // Track whether the input is empty so the clear-action can be hidden via a
+ // data attribute instead of a `:has(input:placeholder-shown)` selector that
+ // forced a descendant re-evaluation on every keystroke.
+ const [uncontrolledEmpty, setUncontrolledEmpty] = React.useState(() => !(props.value ?? props.defaultValue))
+ const isEmpty = props.value !== undefined ? props.value === '' : uncontrolledEmpty
+
const internalOnChange = (event: React.ChangeEvent) => {
// If props.onChange is given, the application controls search,
// otherwise the component does
if (typeof propsOnChange === 'function') propsOnChange(event)
else setSearchQuery(event.target.value)
+ setUncontrolledEmpty(event.target.value === '')
}
const internalKeyDown = (event: React.KeyboardEvent) => {
@@ -464,10 +471,12 @@ const SelectPanelSearchInput: FCWithSlotMarker = ({
// @ts-ignore TODO this is a hacky solution to clear
propsOnChange({target: inputRef.current, currentTarget: inputRef.current})
}
+ setUncontrolledEmpty(true)
}}
/>
}
className={clsx(classes.TextInput, className)}
+ data-empty={isEmpty || undefined}
onChange={internalOnChange}
onKeyDown={internalKeyDown}
{...props}