Skip to content

Commit d65a860

Browse files
authored
fix(dropdown): slotted trigger (#2703)
* fix(dropdown): slotted trigger * fix(dropdown): accessible custom dropdown * fix(dropdown): expanded state * fix(dropdown): menu position * test: fix for bad merge
1 parent 5cc3df9 commit d65a860

File tree

4 files changed

+128
-57
lines changed

4 files changed

+128
-57
lines changed

elements/pf-dropdown/demo/custom-trigger.html

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
1+
<p>
2+
<small>
3+
<em>Note:</em> When using a custom trigger, you must slot in a <code>pf-dropdown-menu</code>
4+
</small>
5+
</p>
6+
17
<pf-dropdown>
28
<pf-button slot="trigger">Toggle dropdown</pf-button>
3-
<pf-dropdown-item>item4</pf-dropdown-item>
4-
<hr>
5-
<pf-dropdown-group label="Group 1">
6-
<pf-dropdown-item>item1</pf-dropdown-item>
7-
<pf-dropdown-item>item2</pf-dropdown-item>
9+
<pf-dropdown-menu slot="menu">
10+
<pf-dropdown-item>item4</pf-dropdown-item>
811
<hr>
9-
<pf-dropdown-item>item3</pf-dropdown-item>
10-
</pf-dropdown-group>
11-
<pf-dropdown-group label="Group 2">
12-
<pf-dropdown-item>item1</pf-dropdown-item>
13-
<pf-dropdown-item>item2</pf-dropdown-item>
14-
<pf-dropdown-item>item3</pf-dropdown-item>
15-
<pf-dropdown-item disabled>disabled</pf-dropdown-item>
16-
</pf-dropdown-group>
12+
<pf-dropdown-group label="Group 1">
13+
<pf-dropdown-item>item1</pf-dropdown-item>
14+
<pf-dropdown-item>item2</pf-dropdown-item>
15+
<hr>
16+
<pf-dropdown-item>item3</pf-dropdown-item>
17+
</pf-dropdown-group>
18+
<pf-dropdown-group label="Group 2">
19+
<pf-dropdown-item>item1</pf-dropdown-item>
20+
<pf-dropdown-item>item2</pf-dropdown-item>
21+
<pf-dropdown-item>item3</pf-dropdown-item>
22+
<pf-dropdown-item disabled>disabled</pf-dropdown-item>
23+
</pf-dropdown-group>
24+
</pf-dropdown-menu>
1725
</pf-dropdown>
1826

1927
<script type="module">

elements/pf-dropdown/pf-dropdown.css

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@ slot[name="trigger"] {
3737
}
3838

3939
pf-dropdown-menu {
40-
display: none;
4140
position: absolute;
4241
top: var(
4342
--pf-c-dropdown__menu--Top,
4443
calc(100% + var(--pf-global--spacer--xs, 0.25rem))
4544
);
45+
left: 0;
4646
z-index: var(
4747
--pf-c-dropdown__menu--ZIndex,
4848
var(--pf-global--ZIndex--sm, 200)
@@ -69,15 +69,11 @@ pf-dropdown-menu {
6969
margin: 0;
7070
}
7171

72-
:host([disabled]) pf-dropdown-menu {
72+
:host([disabled]) :is(pf-dropdown-menu, ::slotted(pf-dropdown-menu)) {
7373
pointer-events: none;
7474
cursor: not-allowed;
7575
}
7676

77-
pf-dropdown-menu.show {
78-
display: block;
79-
}
80-
8177
pf-button.disabled::part(button),
8278
:host([disabled]) ::slotted([slot="trigger"]) {
8379
color: var(---pf-c-dropdown__menu-item--disabled--Color, #6a6e73) !important;

elements/pf-dropdown/pf-dropdown.ts

Lines changed: 98 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,21 @@ import { styleMap } from 'lit/directives/style-map.js';
33
import { classMap } from 'lit/directives/class-map.js';
44
import { customElement } from 'lit/decorators/custom-element.js';
55
import { property } from 'lit/decorators/property.js';
6-
import { query } from 'lit/decorators/query.js';
6+
import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js';
77

8+
import { FloatingDOMController } from '@patternfly/pfe-core/controllers/floating-dom-controller.js';
9+
import { Logger } from '@patternfly/pfe-core/controllers/logger.js';
810
import { PfDropdownItem } from './pf-dropdown-item.js';
911
import { PfDropdownMenu } from './pf-dropdown-menu.js';
12+
import { getRandomId } from '@patternfly/pfe-core/functions/random.js';
1013

1114
import '@patternfly/elements/pf-button/pf-button.js';
1215

1316
import styles from './pf-dropdown.css';
14-
import { FloatingDOMController } from '@patternfly/pfe-core/controllers/floating-dom-controller.js';
17+
18+
function canBeDisabled(el: HTMLElement): el is HTMLElement & { disabled: boolean } {
19+
return 'disabled' in el;
20+
}
1521

1622
export class PfDropdownSelectEvent extends Event {
1723
constructor(
@@ -27,6 +33,8 @@ export class PfDropdownSelectEvent extends Event {
2733
* will trigger a process or navigate to a new location.
2834
*
2935
* @slot - Must contain one or more `<pf-dropdown-item>` or `<pf-dropdown-group>`
36+
* @slot trigger - Custom trigger button
37+
* @slot trigger - menu for custom trigger button
3038
*
3139
* @csspart menu - The dropdown menu wrapper
3240
*
@@ -66,85 +74,138 @@ export class PfDropdown extends LitElement {
6674
*/
6775
@property({ type: Boolean, reflect: true }) expanded = false;
6876

69-
@query('pf-dropdown-menu')
70-
private _menuElement!: PfDropdownMenu;
77+
@queryAssignedElements({ slot: 'trigger', flatten: true })
78+
private _triggerElements!: HTMLElement[];
7179

72-
get #triggerElement() {
73-
return this.shadowRoot?.getElementById('trigger') ?? null;
74-
}
80+
@queryAssignedElements({ slot: 'menu', flatten: true })
81+
private _menuElements!: HTMLElement[];
82+
83+
#logger = new Logger(this);
7584

7685
#float = new FloatingDOMController(this, {
77-
content: () => this._menuElement,
86+
content: () => this._menuElements?.at(0),
7887
});
7988

89+
protected override async getUpdateComplete(): Promise<boolean> {
90+
const ps = await Promise.all([
91+
super.getUpdateComplete(),
92+
this._menuElements?.map(x => (x as LitElement).updateComplete),
93+
]);
94+
return ps.every(x=>!!x);
95+
}
96+
8097
render() {
8198
const { expanded } = this;
8299
const { anchor, alignment, styles = {} } = this.#float;
83100
const { disabled } = this;
84101
return html`
85102
<div class="${classMap({ expanded, [anchor ?? '']: !!anchor, [alignment ?? '']: !!alignment })}"
86-
style="${styleMap(styles)}">
87-
<pf-button id="trigger"
88-
variant="control"
89-
aria-controls="menu"
90-
aria-haspopup="menu"
91-
aria-expanded="${String(expanded) as 'true' | 'false'}"
92-
?disabled="${disabled}"
93-
icon="caret-down"
94-
icon-set="fas"
95-
@keydown="${this.#onButtonKeydown}"
96-
@click="${() => this.toggle()}">Dropdown</pf-button>
97-
<pf-dropdown-menu id="menu"
98-
part="menu"
99-
class="${classMap({ show: expanded })}"
100-
?disabled="${disabled}"
101-
@focusout="${this.#onMenuFocusout}"
102-
@keydown="${this.#onMenuKeydown}"
103-
@click="${this.#onSelect}">
104-
<slot></slot>
105-
</pf-dropdown-menu>
103+
style="${styleMap(styles)}"
104+
@slotchange="${this.#onSlotchange}">
105+
<slot name="trigger"
106+
@keydown="${this.#onButtonKeydown}"
107+
@click="${() => this.toggle()}">
108+
<pf-button variant="control"
109+
icon="caret-down"
110+
icon-set="fas">Dropdown</pf-button>
111+
</slot>
112+
<slot name="menu"
113+
class="${classMap({ show: expanded })}"
114+
?hidden="${!this.expanded}"
115+
@focusout="${this.#onMenuFocusout}"
116+
@keydown="${this.#onMenuKeydown}"
117+
@click="${this.#onSelect}">
118+
<pf-dropdown-menu id="menu"
119+
part="menu"
120+
?disabled="${disabled}">
121+
<slot></slot>
122+
</pf-dropdown-menu>
123+
</slot>
106124
</div>`;
107125
}
108126

127+
override firstUpdated() {
128+
this.#onSlotchange();
129+
}
130+
109131
updated(changed: PropertyValues<this>) {
110132
if (changed.has('expanded')) {
111133
this.#expandedChanged();
112134
}
135+
if (changed.has('disabled')) {
136+
this.#disabledChanged();
137+
}
138+
}
139+
140+
#onSlotchange() {
141+
const [trigger] = this._triggerElements;
142+
const [menu] = this._menuElements;
143+
if (!trigger) {
144+
this.#logger.warn('no trigger found');
145+
} else if (!menu) {
146+
this.#logger.warn('no menu found');
147+
} else if (![trigger, menu].map(x => this.shadowRoot?.contains(x)).every((p, _, a) => p === a[0])) {
148+
this.#logger.warn('trigger and menu must be located in the same root');
149+
} else {
150+
menu.id ||= getRandomId('menu');
151+
trigger.setAttribute('aria-controls', menu.id);
152+
trigger.setAttribute('aria-haspopup', menu.id);
153+
trigger.setAttribute('aria-expanded', String(this.expanded) as 'true' | 'false');
154+
}
113155
}
114156

115157
async #expandedChanged() {
116158
const will = this.expanded ? 'close' : 'open';
159+
const [menu] = this._menuElements;
160+
const [button] = this._triggerElements;
161+
button.setAttribute('aria-expanded', `${String(this.expanded) as 'true' | 'false'}`);
117162
this.dispatchEvent(new Event(will));
118163
if (this.expanded) {
119164
await this.#float.show();
120-
this._menuElement.activeItem?.focus();
165+
if (menu instanceof PfDropdownMenu) {
166+
menu.activeItem?.focus();
167+
}
121168
} else {
122169
await this.#float.hide();
123170
}
124171
}
125172

173+
#disabledChanged() {
174+
for (const el of this._triggerElements.concat(this._menuElements)) {
175+
if (canBeDisabled(el)) {
176+
el.disabled = this.disabled;
177+
}
178+
}
179+
}
126180

127181
#onSelect(event: KeyboardEvent | Event & { target: PfDropdownItem }) {
128-
const menu = this._menuElement;
129-
const target = event.target as PfDropdownItem || menu.activeItem;
130-
this.dispatchEvent(new PfDropdownSelectEvent(event, `${target?.value}`));
131-
this.hide();
182+
const [menu] = this._menuElements;
183+
if (menu instanceof PfDropdownMenu) {
184+
const target = event.target as PfDropdownItem || menu.activeItem;
185+
this.dispatchEvent(new PfDropdownSelectEvent(event, `${target?.value}`));
186+
this.hide();
187+
}
132188
}
133189

134190
#onButtonKeydown(event: KeyboardEvent) {
135191
switch (event.key) {
136-
case 'ArrowDown':
137-
this.show();
192+
case 'ArrowDown': {
193+
if (!this.disabled) {
194+
this.show();
195+
}
196+
}
138197
}
139198
}
140199

141200
#onMenuFocusout(event: FocusEvent) {
142201
if (this.expanded) {
143202
const root = this.getRootNode();
203+
const [menu] = this._menuElements;
144204
if (root instanceof ShadowRoot ||
145205
root instanceof Document &&
146206
event.relatedTarget instanceof PfDropdownItem &&
147-
!this._menuElement.items.includes(event.relatedTarget)
207+
menu instanceof PfDropdownMenu &&
208+
!menu.items.includes(event.relatedTarget)
148209
) {
149210
this.hide();
150211
}
@@ -160,7 +221,7 @@ export class PfDropdown extends LitElement {
160221
break;
161222
case 'Escape':
162223
this.hide();
163-
this.#triggerElement?.focus();
224+
this._triggerElements?.at(0)?.focus();
164225
}
165226
}
166227

elements/pf-dropdown/test/pf-dropdown.spec.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ function press(key: string) {
1313
describe('<pf-dropdown>', function() {
1414
let element: PfDropdown;
1515

16+
const updateComplete = () => element.updateComplete;
17+
1618
describe('simply instantiating', function() {
1719
it('imperatively instantiates', function() {
1820
expect(document.createElement('pf-dropdown')).to.be.an.instanceof(PfDropdown);
@@ -43,18 +45,22 @@ describe('<pf-dropdown>', function() {
4345
ignoredRules: [
4446
/** @see https://github.com/dequelabs/axe-core/issues/4259 */
4547
'aria-allowed-attr',
48+
/** false positive: the menuitem is projected into a menu in another shadow root */
49+
'aria-required-parent',
4650
]
4751
});
4852
});
4953

5054
it('should hide dropdown content from assistive technology', async function() {
5155
const snapshot = await a11ySnapshot();
52-
expect(snapshot.children!.length).to.equal(1);
56+
const menu = snapshot.children?.find(x => x.role === 'menu');
57+
expect(menu).to.not.be.ok;
5358
});
5459

5560
describe('pressing Enter', function() {
5661
beforeEach(press('Tab'));
5762
beforeEach(press('Enter'));
63+
beforeEach(updateComplete);
5864

5965
it('should show menu', async function() {
6066
const snapshot = await a11ySnapshot();

0 commit comments

Comments
 (0)