Skip to content

Commit 5cb4420

Browse files
authored
fix(dropdown): add autoalign behavior (#21185)
* fix(dropdown): add autoalign behavior * test: add tests
1 parent 4ddc1a8 commit 5cb4420

File tree

5 files changed

+200
-8
lines changed

5 files changed

+200
-8
lines changed

packages/web-components/src/components/dropdown/__tests__/dropdown-test.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,90 @@ describe('cds-dropdown', function () {
362362
});
363363
});
364364

365+
describe('auto align behavior', () => {
366+
it('should apply autoalign classes and run floating placement when opened', async () => {
367+
const el = await fixture(html`
368+
<cds-dropdown autoalign title-text="Dropdown Label">
369+
<cds-dropdown-item value="option-1">Option 1</cds-dropdown-item>
370+
</cds-dropdown>
371+
`);
372+
373+
await el.updateComplete;
374+
375+
const listBox = el.shadowRoot.querySelector('.cds--list-box');
376+
expect(listBox.classList.contains('cds--autoalign')).to.be.true;
377+
378+
const menu = el.shadowRoot.querySelector('#menu-body');
379+
const triggerButton = el.shadowRoot.querySelector('#trigger-button');
380+
const placementCalls = [];
381+
const originalSetPlacement = el._floatingController.setPlacement;
382+
el._floatingController.setPlacement = (options) => {
383+
placementCalls.push(options);
384+
};
385+
386+
try {
387+
el.open = true;
388+
await el.updateComplete;
389+
390+
expect(placementCalls.length).to.equal(1);
391+
expect(placementCalls[0].target).to.equal(menu);
392+
expect(placementCalls[0].trigger).to.equal(triggerButton);
393+
expect(placementCalls[0].alignment).to.equal('bottom');
394+
expect(placementCalls[0].matchWidth).to.be.true;
395+
expect(placementCalls[0].open).to.be.true;
396+
397+
el.direction = 'top';
398+
await el.updateComplete;
399+
400+
expect(placementCalls.length).to.equal(2);
401+
expect(placementCalls[1].alignment).to.equal('top');
402+
} finally {
403+
el._floatingController.setPlacement = originalSetPlacement;
404+
}
405+
});
406+
407+
it('should reset floating styles when autoalign is disabled while open', async () => {
408+
const el = await fixture(html`
409+
<cds-dropdown autoalign open title-text="Dropdown Label">
410+
<cds-dropdown-item value="option-1">Option 1</cds-dropdown-item>
411+
</cds-dropdown>
412+
`);
413+
414+
await el.updateComplete;
415+
416+
const menu = el.shadowRoot.querySelector('#menu-body');
417+
const listBox = el.shadowRoot.querySelector('.cds--list-box');
418+
const originalHostDisconnected = el._floatingController.hostDisconnected;
419+
let hostDisconnectedCalled = 0;
420+
el._floatingController.hostDisconnected = () => {
421+
hostDisconnectedCalled += 1;
422+
};
423+
424+
menu.style.left = '10px';
425+
menu.style.top = '5px';
426+
menu.style.position = 'fixed';
427+
menu.style.width = '100px';
428+
menu.style.visibility = 'visible';
429+
menu.setAttribute('align', 'bottom');
430+
431+
try {
432+
el.autoalign = false;
433+
await el.updateComplete;
434+
435+
expect(hostDisconnectedCalled).to.equal(1);
436+
expect(menu.style.left).to.equal('');
437+
expect(menu.style.top).to.equal('');
438+
expect(menu.style.position).to.equal('');
439+
expect(menu.style.width).to.equal('');
440+
expect(menu.style.visibility).to.equal('');
441+
expect(menu.hasAttribute('align')).to.be.false;
442+
expect(listBox.classList.contains('cds--autoalign')).to.be.false;
443+
} finally {
444+
el._floatingController.hostDisconnected = originalHostDisconnected;
445+
}
446+
});
447+
});
448+
365449
describe('events', () => {
366450
it('should fire cds-dropdown-selected event when item is selected', async () => {
367451
const el = await fixture(dropdown);

packages/web-components/src/components/dropdown/dropdown.stories.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const items = [
7777

7878
const defaultArgs = {
7979
ariaLabel: '',
80+
autoalign: false,
8081
direction: DROPDOWN_DIRECTION.BOTTOM,
8182
disabled: false,
8283
hideLabel: false,
@@ -100,6 +101,11 @@ const controls = {
100101
description:
101102
'Specify a label to be read by screen readers on the container node.',
102103
},
104+
autoalign: {
105+
control: 'boolean',
106+
description:
107+
'Will auto-align the dropdown. This attribute is currently experimental and is subject to future changes.',
108+
},
103109
direction: {
104110
control: 'select',
105111
options: directionOptions,
@@ -175,6 +181,7 @@ export const Default = {
175181
},
176182
render: ({
177183
ariaLabel,
184+
autoalign,
178185
open,
179186
direction,
180187
disabled,
@@ -193,6 +200,7 @@ export const Default = {
193200
}) => html`
194201
<cds-dropdown
195202
aria-label=${ariaLabel}
203+
?autoalign=${autoalign}
196204
?open=${open}
197205
?disabled="${disabled}"
198206
?hide-label=${hideLabel}
@@ -223,14 +231,16 @@ export const ExperimentalAutoAlign = {
223231
argTypes: controls,
224232
args: {
225233
...defaultArgs,
226-
direction: DROPDOWN_DIRECTION.TOP,
234+
autoalign: true,
235+
direction: DROPDOWN_DIRECTION.BOTTOM,
227236
helperText: 'This is some helper text',
228237
label: 'Option 1',
229238
titleText: 'Dropdown label',
230239
value: 'option-1',
231240
},
232241
render: ({
233242
ariaLabel,
243+
autoalign,
234244
open,
235245
direction,
236246
disabled,
@@ -251,6 +261,7 @@ export const ExperimentalAutoAlign = {
251261
<div style="height: 300px"></div>
252262
<cds-dropdown
253263
aria-label=${ariaLabel}
264+
?autoalign=${autoalign}
254265
?open=${open}
255266
?disabled="${disabled}"
256267
?hide-label=${hideLabel}
@@ -290,6 +301,7 @@ export const Inline = {
290301
},
291302
render: ({
292303
ariaLabel,
304+
autoalign,
293305
open,
294306
direction,
295307
disabled,
@@ -308,6 +320,7 @@ export const Inline = {
308320
}) => html`
309321
<cds-dropdown
310322
aria-label=${ariaLabel}
323+
?autoalign=${autoalign}
311324
?open=${open}
312325
?disabled="${disabled}"
313326
?hide-label=${hideLabel}
@@ -345,6 +358,7 @@ export const InlineWithLayer = {
345358
},
346359
render: ({
347360
ariaLabel,
361+
autoalign,
348362
open,
349363
direction,
350364
disabled,
@@ -365,6 +379,7 @@ export const InlineWithLayer = {
365379
<div style="width:400px">
366380
<cds-dropdown
367381
aria-label=${ariaLabel}
382+
?autoalign=${autoalign}
368383
?open=${open}
369384
?disabled="${disabled}"
370385
?hide-label=${hideLabel}
@@ -448,6 +463,7 @@ export const WithAILabel = {
448463
},
449464
render: ({
450465
ariaLabel,
466+
autoalign,
451467
open,
452468
direction,
453469
disabled,
@@ -466,6 +482,7 @@ export const WithAILabel = {
466482
}) => html`
467483
<cds-dropdown
468484
aria-label=${ariaLabel}
485+
?autoalign=${autoalign}
469486
?open=${open}
470487
?disabled="${disabled}"
471488
?hide-label=${hideLabel}
@@ -504,6 +521,7 @@ export const WithLayer = {
504521
},
505522
render: ({
506523
ariaLabel,
524+
autoalign,
507525
open,
508526
direction,
509527
disabled,
@@ -524,6 +542,7 @@ export const WithLayer = {
524542
<div style="width:400px">
525543
<cds-dropdown
526544
aria-label=${ariaLabel}
545+
?autoalign=${autoalign}
527546
?open=${open}
528547
?disabled="${disabled}"
529548
?hide-label=${hideLabel}
@@ -564,6 +583,7 @@ export const WithToggletipLabel = {
564583
},
565584
render: ({
566585
ariaLabel,
586+
autoalign,
567587
open,
568588
direction,
569589
disabled,
@@ -581,6 +601,7 @@ export const WithToggletipLabel = {
581601
}) => html`
582602
<cds-dropdown
583603
aria-label=${ariaLabel}
604+
?autoalign=${autoalign}
584605
?open=${open}
585606
?disabled="${disabled}"
586607
?hide-label=${hideLabel}

packages/web-components/src/components/dropdown/dropdown.ts

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import FormMixin from '../../globals/mixins/form';
1919
import HostListenerMixin from '../../globals/mixins/host-listener';
2020
import ValidityMixin from '../../globals/mixins/validity';
2121
import HostListener from '../../globals/decorators/host-listener';
22+
import FloatingUIController from '../../globals/controllers/floating-controller';
2223
import {
2324
find,
2425
forEach,
@@ -75,6 +76,11 @@ class CDSDropdown extends ValidityMixin(
7576
*/
7677
private _aiDecoratorNodes: HTMLElement[] = [];
7778

79+
/**
80+
* Floating UI controller instance for autoalign positioning.
81+
*/
82+
private _floatingController = new FloatingUIController(this);
83+
7884
/**
7985
* Handles interaction on an AI decorator while the menu is open.
8086
*/
@@ -106,6 +112,18 @@ class CDSDropdown extends ValidityMixin(
106112
@query(`.${prefix}--list-box`)
107113
protected _listBoxNode!: HTMLDivElement;
108114

115+
/**
116+
* The menu body element.
117+
*/
118+
@query('#menu-body')
119+
protected _menuBodyNode!: HTMLDivElement;
120+
121+
/**
122+
* The trigger button element.
123+
*/
124+
@query('#trigger-button')
125+
protected _triggerButtonNode!: HTMLDivElement;
126+
109127
/**
110128
* The `<slot>` element for the helper text in the shadow DOM.
111129
*/
@@ -938,6 +956,12 @@ class CDSDropdown extends ValidityMixin(
938956
@property({ type: String, reflect: true })
939957
direction = DROPDOWN_DIRECTION.BOTTOM;
940958

959+
/**
960+
* Specify whether auto align functionality should be applied
961+
*/
962+
@property({ type: Boolean, reflect: true })
963+
autoalign = false;
964+
941965
/**
942966
* `true` if this dropdown should be disabled.
943967
*/
@@ -1112,8 +1136,7 @@ class CDSDropdown extends ValidityMixin(
11121136
return true;
11131137
}
11141138

1115-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
1116-
updated(_changedProperties) {
1139+
updated(changedProperties) {
11171140
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- https://github.com/carbon-design-system/carbon/issues/20452
11181141
this._hasAILabel
11191142
? this.setAttribute('ai-label', '')
@@ -1133,6 +1156,69 @@ class CDSDropdown extends ValidityMixin(
11331156
this.querySelector(`${prefix}-slug`)?.hasAttribute('revert-active')
11341157
);
11351158
}
1159+
1160+
if (
1161+
this.autoalign &&
1162+
this.open &&
1163+
(changedProperties.has('open') ||
1164+
(changedProperties.has('autoalign') && this.autoalign) ||
1165+
changedProperties.has('direction') ||
1166+
changedProperties.has('size'))
1167+
) {
1168+
this._updateAutoAlignPlacement();
1169+
} else if (
1170+
(changedProperties.has('autoalign') && !this.autoalign) ||
1171+
(changedProperties.has('open') && !this.open)
1172+
) {
1173+
this._floatingController.hostDisconnected();
1174+
this._resetFloatingStyles();
1175+
}
1176+
}
1177+
1178+
/**
1179+
* Clears Floating UI styles when auto-align is off or the menu closes.
1180+
*/
1181+
private _resetFloatingStyles() {
1182+
const menu = this._menuBodyNode;
1183+
if (!menu) {
1184+
return;
1185+
}
1186+
1187+
menu.style.removeProperty('left');
1188+
menu.style.removeProperty('top');
1189+
menu.style.removeProperty('position');
1190+
menu.style.removeProperty('width');
1191+
menu.style.removeProperty('visibility');
1192+
menu.removeAttribute('align');
1193+
}
1194+
1195+
/**
1196+
* Runs Floating UI placement while auto-align is active.
1197+
*/
1198+
private _updateAutoAlignPlacement() {
1199+
if (!this.autoalign || !this.open) {
1200+
return;
1201+
}
1202+
1203+
const menu = this._menuBodyNode;
1204+
const trigger = this._triggerButtonNode || this._listBoxNode;
1205+
1206+
if (!menu || !trigger) {
1207+
return;
1208+
}
1209+
1210+
const alignment =
1211+
this.direction === DROPDOWN_DIRECTION.TOP
1212+
? DROPDOWN_DIRECTION.TOP
1213+
: DROPDOWN_DIRECTION.BOTTOM;
1214+
1215+
this._floatingController.setPlacement({
1216+
alignment,
1217+
matchWidth: true,
1218+
open: this.open,
1219+
target: menu,
1220+
trigger,
1221+
});
11361222
}
11371223

11381224
/**
@@ -1152,7 +1238,7 @@ class CDSDropdown extends ValidityMixin(
11521238
*/
11531239
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452
11541240
protected get _classes(): any {
1155-
const { size, type, open } = this;
1241+
const { size, type, open, autoalign } = this;
11561242
const inline = type === DROPDOWN_TYPE.INLINE;
11571243
const normalizedProps = this._normalizedProps;
11581244

@@ -1172,6 +1258,7 @@ class CDSDropdown extends ValidityMixin(
11721258
[`${prefix}--dropdown--inline`]: inline,
11731259
[`${prefix}--dropdown--selected`]: selectedItemsCount > 0,
11741260
[`${prefix}--list-box__wrapper--decorator`]: this._hasAILabel,
1261+
[`${prefix}--autoalign`]: autoalign,
11751262
});
11761263
}
11771264

packages/web-components/src/components/popover/popover.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import styles from './popover.scss?lit';
1414
import CDSPopoverContent from './popover-content';
1515
import HostListener from '../../globals/decorators/host-listener';
1616
import HostListenerMixin from '../../globals/mixins/host-listener';
17-
import FloatingUIContoller from '../../globals/controllers/floating-controller';
17+
import FloatingUIController from '../../globals/controllers/floating-controller';
1818
import { POPOVER_BACKGROUND_TOKEN } from './defs';
1919
import type { Boundary, Rect } from '@floating-ui/dom';
2020

@@ -28,7 +28,7 @@ class CDSPopover extends HostListenerMixin(LitElement) {
2828
/**
2929
* Create popover controller instance
3030
*/
31-
private popoverController = new FloatingUIContoller(this);
31+
private popoverController = new FloatingUIController(this);
3232

3333
/**
3434
* The `<slot>` element in the shadow DOM.

0 commit comments

Comments
 (0)