Skip to content

Commit bf7278c

Browse files
authored
fix(web-components): remove dropdown manual slot assignment (microsoft#35125)
1 parent e1d04d5 commit bf7278c

11 files changed

+196
-50
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "prerelease",
3+
"comment": "fix(dropdown): use slotchange events instead of manual slot assignment",
4+
"packageName": "@fluentui/web-components",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

packages/web-components/docs/web-components.api.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -630,8 +630,6 @@ export class BaseDropdown extends FASTElement {
630630
// @internal
631631
listboxChanged(prev: Listbox | undefined, next: Listbox | undefined): void;
632632
// @internal
633-
listboxSlot: HTMLSlotElement;
634-
// @internal
635633
mousedownHandler(e: MouseEvent): boolean | void;
636634
multiple?: boolean;
637635
// @internal
@@ -651,6 +649,8 @@ export class BaseDropdown extends FASTElement {
651649
selectOption(index?: number, shouldEmit?: boolean): void;
652650
// @internal
653651
setValidity(flags?: Partial<ValidityState>, message?: string, anchor?: HTMLElement): void;
652+
// @internal
653+
slotchangeHandler(e: Event): boolean | void;
654654
type: DropdownType;
655655
// @internal
656656
typeChanged(prev: DropdownType | undefined, next: DropdownType | undefined): void;
@@ -2945,8 +2945,6 @@ export class Listbox extends FASTElement {
29452945
// @internal
29462946
beforetoggleHandler(e: ToggleEvent): boolean | undefined;
29472947
clickHandler(e: PointerEvent): boolean | void;
2948-
// (undocumented)
2949-
connectedCallback(): void;
29502948
// @internal
29512949
dropdown?: BaseDropdown;
29522950
// @internal
@@ -2966,6 +2964,7 @@ export class Listbox extends FASTElement {
29662964
selectedIndex: number;
29672965
get selectedOptions(): DropdownOption[];
29682966
selectOption(index?: number): void;
2967+
slotchangeHandler(e: Event): void;
29692968
}
29702969

29712970
// @public

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

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { toggleState } from '../utils/element-internals.js';
88
import { getLanguage } from '../utils/language.js';
99
import { AnchorPositioningCSSSupported } from '../utils/support.js';
1010
import { uniqueId } from '../utils/unique-id.js';
11+
import { waitForConnectedDescendants } from '../utils/request-idle-callback.js';
1112
import { DropdownType } from './dropdown.options.js';
1213
import { dropdownButtonTemplate, dropdownInputTemplate } from './dropdown.template.js';
1314

@@ -105,7 +106,6 @@ export class BaseDropdown extends FASTElement {
105106
public controlChanged(prev: HTMLInputElement | undefined, next: HTMLInputElement | undefined): void {
106107
if (next) {
107108
next.id = next.id || uniqueId('input-');
108-
this.controlSlot?.assign(next);
109109
}
110110
}
111111

@@ -224,7 +224,7 @@ export class BaseDropdown extends FASTElement {
224224
next.dropdown = this;
225225
next.popover = 'manual';
226226
next.tabIndex = -1;
227-
this.listboxSlot.assign(next);
227+
228228
const notifier = Observable.getNotifier(this);
229229
notifier.subscribe(next);
230230

@@ -252,14 +252,6 @@ export class BaseDropdown extends FASTElement {
252252
}
253253
}
254254

255-
/**
256-
* Reference to the listbox slot element.
257-
*
258-
* @internal
259-
*/
260-
@observable
261-
public listboxSlot!: HTMLSlotElement;
262-
263255
/**
264256
* Indicates whether the dropdown allows multiple options to be selected.
265257
*
@@ -693,8 +685,6 @@ export class BaseDropdown extends FASTElement {
693685

694686
this.elementInternals.role = 'presentation';
695687

696-
this.addEventListener('connected', this.listboxConnectedHandler);
697-
698688
Updates.enqueue(() => {
699689
this.insertControl();
700690
});
@@ -974,6 +964,24 @@ export class BaseDropdown extends FASTElement {
974964
}
975965
}
976966

967+
/**
968+
* Handles the `slotchange` event for the dropdown.
969+
* Sets the `listbox` property when a valid listbox is assigned to the default slot.
970+
*
971+
* @param e - the slot change event
972+
* @internal
973+
*/
974+
public slotchangeHandler(e: Event): boolean | void {
975+
const target = e.target as HTMLSlotElement;
976+
977+
waitForConnectedDescendants(this, () => {
978+
const listbox = target.assignedElements().find((el): el is Listbox => isListbox(el));
979+
if (listbox) {
980+
this.listbox = listbox;
981+
}
982+
});
983+
}
984+
977985
/**
978986
* Updates the freeform option with the provided value.
979987
*
@@ -1009,20 +1017,6 @@ export class BaseDropdown extends FASTElement {
10091017
super.disconnectedCallback();
10101018
}
10111019

1012-
/**
1013-
* Handles the connected event for the listbox.
1014-
*
1015-
* @param e - the event object
1016-
* @internal
1017-
*/
1018-
private listboxConnectedHandler(e: Event): void {
1019-
const target = e.target as HTMLElement;
1020-
1021-
if (isListbox(target)) {
1022-
this.listbox = target;
1023-
}
1024-
}
1025-
10261020
/**
10271021
* When anchor positioning isn't supported, an intersection observer is used to flip the listbox when it hits the
10281022
* viewport bounds. One static observer is used for all dropdowns.

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,4 @@ export const definition = Dropdown.compose({
1414
name: `${FluentDesignSystem.prefix}-dropdown`,
1515
template,
1616
styles,
17-
shadowOptions: {
18-
slotAssignment: 'manual',
19-
},
2017
});

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,22 @@ export default {
7272
control: 'text',
7373
table: { category: 'attributes' },
7474
},
75+
disabled: {
76+
control: 'boolean',
77+
table: { category: 'attributes' },
78+
},
7579
multiple: {
7680
control: 'boolean',
7781
table: { category: 'attributes' },
7882
},
83+
name: {
84+
control: 'text',
85+
table: { category: 'attributes' },
86+
},
87+
required: {
88+
control: 'boolean',
89+
table: { category: 'attributes' },
90+
},
7991
size: {
8092
control: 'select',
8193
options: ['', ...Object.values(DropdownSize)],

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const dropdownInputTemplate = html<BaseDropdown>`
3535
?disabled="${x => x.disabled}"
3636
type="${x => x.type}"
3737
value="${x => x.valueAttribute}"
38+
slot="control"
3839
${ref('control')}
3940
/>
4041
`;
@@ -57,6 +58,7 @@ export const dropdownButtonTemplate = html<BaseDropdown>`
5758
role="combobox"
5859
?disabled="${x => x.disabled}"
5960
type="button"
61+
slot="control"
6062
${ref('control')}
6163
>
6264
${x => x.displayValue}
@@ -83,7 +85,7 @@ export function dropdownTemplate<T extends BaseDropdown>(options: DropdownOption
8385
<slot name="control" ${ref('controlSlot')}></slot>
8486
<slot name="indicator" ${ref('indicatorSlot')}>${staticallyCompose(options.indicator)}</slot>
8587
</div>
86-
<slot ${ref('listboxSlot')}></slot>
88+
<slot @slotchange="${(x, c) => x.slotchangeHandler(c.event)}"></slot>
8789
</template>
8890
`;
8991
}

packages/web-components/src/listbox/listbox.template.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { ElementViewTemplate, html, slotted } from '@microsoft/fast-element';
2-
import { isDropdownOption } from '../option/option.options.js';
1+
import { type ElementViewTemplate, html } from '@microsoft/fast-element';
32
import type { Listbox } from './listbox.js';
43

54
/**
65
* Generates a template for the {@link (Dropdown:class)} component.
76
*
8-
* @param options - The {@link (DropdownOptions:interface)} to use for generating the template.
97
* @returns The template object.
108
*
119
* @public
@@ -17,12 +15,7 @@ export function listboxTemplate<T extends Listbox>(): ElementViewTemplate<T> {
1715
@beforetoggle="${(x, c) => x.beforetoggleHandler(c.event as ToggleEvent)}"
1816
@click="${(x, c) => x.clickHandler(c.event as PointerEvent)}"
1917
>
20-
<slot
21-
${slotted({
22-
property: 'options',
23-
filter: node => isDropdownOption(node),
24-
})}
25-
></slot>
18+
<slot @slotchange="${(x, c) => x.slotchangeHandler(c.event)}"></slot>
2619
</template>
2720
`;
2821
}

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { BaseDropdown } from '../dropdown/dropdown.base.js';
33
import type { DropdownOption } from '../option/option.js';
44
import { isDropdownOption } from '../option/option.options.js';
55
import { toggleState } from '../utils/element-internals.js';
6+
import { waitForConnectedDescendants } from '../utils/request-idle-callback.js';
67
import { uniqueId } from '../utils/unique-id.js';
78

89
/**
@@ -12,7 +13,6 @@ import { uniqueId } from '../utils/unique-id.js';
1213
* @tag fluent-listbox
1314
*
1415
* @slot - The default slot for the options.
15-
* @emits connected - Dispatched when the element is connected to the DOM.
1616
*
1717
* @remarks
1818
* The listbox component represents a list of options that can be selected.
@@ -165,12 +165,6 @@ export class Listbox extends FASTElement {
165165
this.elementInternals.role = 'listbox';
166166
}
167167

168-
connectedCallback(): void {
169-
super.connectedCallback();
170-
171-
this.$emit('connected');
172-
}
173-
174168
/**
175169
* Handles observable subscriptions for the listbox.
176170
*
@@ -213,4 +207,21 @@ export class Listbox extends FASTElement {
213207

214208
this.selectedIndex = selectedIndex;
215209
}
210+
211+
/**
212+
* Handles the `slotchange` event for the default slot.
213+
* Sets the `options` property to the list of slotted options.
214+
*
215+
* @param e - The slotchange event
216+
* @public
217+
*/
218+
public slotchangeHandler(e: Event): void {
219+
waitForConnectedDescendants(this, () => {
220+
const options = (e.target as HTMLSlotElement)
221+
.assignedElements()
222+
.filter<DropdownOption>((option): option is DropdownOption => isDropdownOption(option));
223+
224+
this.options = options;
225+
});
226+
}
216227
}

packages/web-components/src/option/option.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,26 @@ test.describe('DropdownOption', () => {
7777
await expect(element).toHaveJSProperty('elementInternals.ariaDisabled', 'false');
7878
});
7979

80+
test('removing the `disabled` attribute should set the `disabled` property to false', async ({ fastPage }) => {
81+
const { element } = fastPage;
82+
83+
await fastPage.setTemplate({ attributes: { disabled: true } });
84+
85+
await expect(element).toHaveJSProperty('disabled', true);
86+
87+
await expect(element).toHaveJSProperty('elementInternals.ariaDisabled', 'true');
88+
89+
await element.evaluate((node: DropdownOption) => {
90+
node.removeAttribute('disabled');
91+
});
92+
93+
await expect(element).not.toHaveAttribute('disabled');
94+
95+
await expect(element).toHaveJSProperty('disabled', false);
96+
97+
await expect(element).toHaveJSProperty('elementInternals.ariaDisabled', 'false');
98+
});
99+
80100
test('should NOT set the `selected` attribute to match the `selected` property when the `selected` attribute is NOT present', async ({
81101
fastPage,
82102
}) => {
@@ -134,4 +154,26 @@ test.describe('DropdownOption', () => {
134154

135155
await expect(element).toHaveJSProperty('elementInternals.ariaSelected', 'false');
136156
});
157+
158+
test('should unset the form value when the option is disabled and selected', async ({ fastPage, page }) => {
159+
const { element } = fastPage;
160+
161+
await fastPage.setTemplate(/* html */ `
162+
<form id="test-form" action="#">
163+
<fluent-option name="option" value="hello" selected>Hello</fluent-option>
164+
</form>
165+
`);
166+
167+
const form = page.locator('#test-form');
168+
169+
expect(await form.evaluate((node: HTMLFormElement) => Array.from(new FormData(node).entries()))).toEqual([
170+
['option', 'hello'],
171+
]);
172+
173+
await element.evaluate((node: DropdownOption) => {
174+
node.disabled = true;
175+
});
176+
177+
expect(await form.evaluate((node: HTMLFormElement) => Array.from(new FormData(node).entries()))).toEqual([]);
178+
});
137179
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export class DropdownOption extends FASTElement implements Start {
118118
protected disabledChanged(prev: boolean | undefined, next: boolean | undefined): void {
119119
this.elementInternals.ariaDisabled = this.disabled ? 'true' : 'false';
120120
toggleState(this.elementInternals, 'disabled', this.disabled);
121+
this.setFormValue(!this.disabled && this.selected ? this.value : null);
121122
}
122123

123124
/**

0 commit comments

Comments
 (0)