Skip to content

Commit 8b2a5d5

Browse files
authored
fix(combo-box): correct focus and item highlight behavior (#21057)
* fix(combo-box): correct focus and item highlight behavior * fix(combo-box): show not-allowed cursor on disabled items * fix(combo-box): respect disabled items during filter highlight
1 parent b486103 commit 8b2a5d5

File tree

3 files changed

+166
-5
lines changed

3 files changed

+166
-5
lines changed

packages/web-components/src/components/combo-box/combo-box-item.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,102 @@
11
/**
2-
* Copyright IBM Corp. 2019, 2023
2+
* Copyright IBM Corp. 2019, 2025
33
*
44
* This source code is licensed under the Apache-2.0 license found in the
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
import type { PropertyValues } from 'lit';
89
import { prefix } from '../../globals/settings';
910
import CDSDropdownItem from '../dropdown/dropdown-item';
1011
import styles from './combo-box.scss?lit';
1112
import { carbonElement as customElement } from '../../globals/decorators/carbon-element';
1213

14+
type NextSiblingAttribute =
15+
| 'hovered-next-sibling'
16+
| 'highlighted-next-sibling'
17+
| 'selected-next-sibling';
18+
1319
/**
1420
* Combo box item.
1521
*
1622
* @element cds-combo-box-item
1723
*/
1824
@customElement(`${prefix}-combo-box-item`)
1925
class CDSComboBoxItem extends CDSDropdownItem {
26+
private _nextSiblingRefs: Record<NextSiblingAttribute, Element | null> = {
27+
'hovered-next-sibling': null,
28+
'highlighted-next-sibling': null,
29+
'selected-next-sibling': null,
30+
};
31+
32+
private _handleMouseEnter = () => {
33+
if (this.hasAttribute('disabled')) {
34+
return;
35+
}
36+
this._syncNextSibling('hovered-next-sibling', true);
37+
};
38+
39+
private _handleMouseLeave = () => {
40+
this._syncNextSibling('hovered-next-sibling', false);
41+
};
42+
43+
connectedCallback() {
44+
super.connectedCallback();
45+
this.classList.add(`${prefix}--list-box__menu-item`);
46+
this.addEventListener('mouseenter', this._handleMouseEnter);
47+
this.addEventListener('mouseleave', this._handleMouseLeave);
48+
}
49+
50+
disconnectedCallback() {
51+
this.removeEventListener('mouseenter', this._handleMouseEnter);
52+
this.removeEventListener('mouseleave', this._handleMouseLeave);
53+
this._syncNextSibling('hovered-next-sibling', false);
54+
this._syncNextSibling('highlighted-next-sibling', false);
55+
this._syncNextSibling('selected-next-sibling', false);
56+
super.disconnectedCallback();
57+
}
58+
59+
private _getNextItem(): Element | null {
60+
let next = this.nextElementSibling;
61+
while (next) {
62+
if (
63+
next instanceof HTMLElement &&
64+
next.tagName.toLowerCase() === `${prefix}-combo-box-item`
65+
) {
66+
return next;
67+
}
68+
next = next.nextElementSibling;
69+
}
70+
return null;
71+
}
72+
73+
private _syncNextSibling(
74+
attribute: NextSiblingAttribute,
75+
shouldSet: boolean
76+
) {
77+
const currentSibling = this._nextSiblingRefs[attribute];
78+
currentSibling?.removeAttribute(attribute);
79+
if (shouldSet) {
80+
const next = this._getNextItem();
81+
if (next) {
82+
next.setAttribute(attribute, '');
83+
this._nextSiblingRefs[attribute] = next;
84+
return;
85+
}
86+
}
87+
this._nextSiblingRefs[attribute] = null;
88+
}
89+
90+
protected updated(changedProperties: PropertyValues) {
91+
super.updated(changedProperties);
92+
if (changedProperties.has('highlighted')) {
93+
this._syncNextSibling('highlighted-next-sibling', this.highlighted);
94+
}
95+
if (changedProperties.has('selected')) {
96+
this._syncNextSibling('selected-next-sibling', this.selected);
97+
}
98+
}
99+
20100
static styles = styles;
21101
}
22102

packages/web-components/src/components/combo-box/combo-box.scss

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ $css--plex: true !default;
1111
@use '@carbon/styles/scss/spacing' as *;
1212
@use '@carbon/styles/scss/theme' as *;
1313
@use '@carbon/styles/scss/utilities' as *;
14+
@use '@carbon/styles/scss/utilities/focus-outline' as *;
1415
@use '@carbon/styles/scss/utilities/convert' as *;
1516
@use '@carbon/styles/scss/layout' as *;
1617
@use '@carbon/styles/scss/components/combo-box' as *;
@@ -41,6 +42,11 @@ $css--plex: true !default;
4142
}
4243
}
4344

45+
:host(#{$prefix}-combo-box) .#{$prefix}--text-input--highlighted-outline {
46+
outline: 1px solid $focus;
47+
outline-offset: -1px;
48+
}
49+
4450
:host(#{$prefix}-combo-box[disabled]),
4551
:host(#{$prefix}-combo-box[read-only]) {
4652
.#{$prefix}--list-box__selection {
@@ -68,29 +74,70 @@ $css--plex: true !default;
6874
.#{$prefix}--list-box__menu-item__option {
6975
block-size: auto;
7076
}
77+
}
78+
79+
:host(#{$prefix}-combo-box-item:not([disabled]):hover) {
80+
background-color: $layer-hover;
81+
}
7182

72-
&:hover {
73-
background-color: $layer-hover;
83+
:host(#{$prefix}-combo-box-item:first-of-type)
84+
.#{$prefix}--list-box__menu-item__option {
85+
border-block-start-color: transparent;
86+
}
87+
88+
:host(#{$prefix}-combo-box-item:first-of-type[highlighted]) {
89+
@include focus-outline('reset');
90+
91+
&::before {
92+
position: absolute;
93+
border: 2px solid $focus;
94+
block-size: 100%;
95+
border-block-start: 1px solid $focus;
96+
content: '';
97+
inline-size: 100%;
98+
inset-block-start: 0;
99+
inset-inline-start: 0;
100+
pointer-events: none;
74101
}
75102
}
76103

104+
:host(#{$prefix}-combo-box-item[highlighted-next-sibling]),
105+
:host(#{$prefix}-combo-box-item[hovered-next-sibling])
106+
.#{$prefix}--list-box__menu-item__option {
107+
border-block-start-color: transparent;
108+
}
109+
77110
:host(#{$prefix}-combo-box-item[disabled]) {
111+
cursor: not-allowed;
112+
78113
.#{$prefix}--list-box__menu-item__option {
79114
color: $text-disabled;
80115
cursor: not-allowed;
116+
pointer-events: none;
81117
text-decoration: none;
82118
}
83119
}
84120

121+
:host(#{$prefix}-combo-box-item[disabled][highlighted-next-sibling]:hover),
122+
:host(#{$prefix}-combo-box-item[disabled][hovered-next-sibling]:hover)
123+
.#{$prefix}--list-box__menu-item__option {
124+
border-block-start-color: $border-subtle;
125+
}
126+
127+
:host(#{$prefix}-combo-box-item[disabled]:hover)
128+
.#{$prefix}--list-box__menu-item__option {
129+
border-block-start-color: $border-subtle-01;
130+
}
131+
85132
:host(#{$prefix}-combo-box-item[highlighted]) {
86133
@extend .#{$prefix}--list-box__menu-item--highlighted;
87134
}
88135

89136
:host(#{$prefix}-combo-box-item[selected]) {
90137
@extend .#{$prefix}--list-box__menu-item--active;
91-
@extend .#{$prefix}--list-box__menu-item--highlighted;
92138

93139
.#{$prefix}--list-box__menu-item__option {
140+
border-block-start-color: transparent;
94141
color: $text-primary;
95142
}
96143

@@ -99,6 +146,16 @@ $css--plex: true !default;
99146
}
100147
}
101148

149+
:host(#{$prefix}-combo-box-item[selected-next-sibling])
150+
.#{$prefix}--list-box__menu-item__option {
151+
border-block-start-color: transparent;
152+
}
153+
154+
:host(#{$prefix}-combo-box-item[disabled][selected-next-sibling]:hover)
155+
.#{$prefix}--list-box__menu-item__option {
156+
border-block-start-color: $border-subtle;
157+
}
158+
102159
:host(#{$prefix}-combo-box-item[size='sm']) {
103160
block-size: $spacing-07;
104161

@@ -115,6 +172,18 @@ $css--plex: true !default;
115172
}
116173
}
117174

175+
:host(#{$prefix}-combo-box[size='sm']) {
176+
.#{$prefix}--list-box__menu-icon {
177+
inset-block: $spacing-02;
178+
}
179+
}
180+
181+
:host(#{$prefix}-combo-box[size='lg']) {
182+
.#{$prefix}--list-box__menu-icon {
183+
inset-block: $spacing-04;
184+
}
185+
}
186+
118187
:host(#{$prefix}-combo-box[ai-label]) {
119188
@extend .#{$prefix}--list-box__wrapper--slug;
120189

packages/web-components/src/components/combo-box/combo-box.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ class CDSComboBox extends CDSDropdown {
226226
} else {
227227
(comboItem as HTMLElement).style.display = '';
228228
}
229-
comboItem.highlighted = index === firstMatchIndex;
229+
comboItem.highlighted = index === firstMatchIndex && !comboItem.disabled;
230230
});
231231
return firstMatchIndex;
232232
}
@@ -389,6 +389,7 @@ class CDSComboBox extends CDSDropdown {
389389
const inputClasses = classMap({
390390
[`${prefix}--text-input`]: true,
391391
[`${prefix}--text-input--empty`]: !value,
392+
[`${prefix}--text-input--highlighted-outline`]: this._hasHighlightedItem,
392393
});
393394

394395
let activeDescendantFallback: string | undefined;
@@ -420,6 +421,17 @@ class CDSComboBox extends CDSDropdown {
420421
`;
421422
}
422423

424+
protected get _hasHighlightedItem() {
425+
return (
426+
this.open &&
427+
Boolean(
428+
this.querySelector(
429+
(this.constructor as typeof CDSComboBox).selectorItemHighlighted
430+
)
431+
)
432+
);
433+
}
434+
423435
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- https://github.com/carbon-design-system/carbon/issues/20452
424436
protected _renderFollowingLabel(): TemplateResult | void {
425437
const { clearSelectionLabel, _filterInputValue: filterInputValue } = this;

0 commit comments

Comments
 (0)