From 7348cae59a50483fc385adbc61d7073395f86a5b Mon Sep 17 00:00:00 2001 From: Brian Brady Date: Mon, 11 May 2026 12:46:19 -0700 Subject: [PATCH 1/4] fixes dropdown value set before listbox init --- ...-7f7e0c28-40cb-4fce-8b89-60bd96f75f41.json | 7 + packages/web-components/package.json | 7 +- .../src/dropdown/dropdown.base.ts | 73 ++++++++- .../src/dropdown/dropdown.spec.ts | 140 ++++++++++++++++++ 4 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 change/@fluentui-web-components-7f7e0c28-40cb-4fce-8b89-60bd96f75f41.json diff --git a/change/@fluentui-web-components-7f7e0c28-40cb-4fce-8b89-60bd96f75f41.json b/change/@fluentui-web-components-7f7e0c28-40cb-4fce-8b89-60bd96f75f41.json new file mode 100644 index 00000000000000..74f89f9a08b380 --- /dev/null +++ b/change/@fluentui-web-components-7f7e0c28-40cb-4fce-8b89-60bd96f75f41.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "fix: apply dropdown value set before initialization", + "packageName": "@fluentui/web-components", + "email": "brianbrady@microsoft.com", + "dependentChangeType": "none" +} diff --git a/packages/web-components/package.json b/packages/web-components/package.json index cbed415018f1e3..adbd62002830cb 100644 --- a/packages/web-components/package.json +++ b/packages/web-components/package.json @@ -78,14 +78,15 @@ "@custom-elements-manifest/analyzer": "0.10.10", "@microsoft/fast-element": "2.0.0", "@microsoft/focusgroup-polyfill": "^1.4.1", - "@tensile-perf/web-components": "~0.2.2", "@storybook/html": "9.1.17", "@storybook/html-vite": "9.1.17", - "@wc-toolkit/cem-validator": "1.0.3", + "@tensile-perf/web-components": "~0.2.2", "@wc-toolkit/cem-inheritance": "1.2.2", + "@wc-toolkit/cem-validator": "1.0.3", "@wc-toolkit/module-path-resolver": "1.0.0", "@wc-toolkit/type-parser": "1.0.3", - "chromedriver": "^125.0.0" + "chromedriver": "^125.0.0", + "storybook": "9.1.17" }, "dependencies": { "@fluentui/tokens": "1.0.0-alpha.23", diff --git a/packages/web-components/src/dropdown/dropdown.base.ts b/packages/web-components/src/dropdown/dropdown.base.ts index 8f14dbd1ae7243..a605edfd740488 100644 --- a/packages/web-components/src/dropdown/dropdown.base.ts +++ b/packages/web-components/src/dropdown/dropdown.base.ts @@ -207,6 +207,20 @@ export class BaseDropdown extends FASTElement { @observable public listbox!: Listbox; + /** + * Stores a value set before the listbox/options are ready. + * + * @internal + */ + private pendingValue: string | null | undefined; + + /** + * Tracks whether the listbox has completed its default selection setup. + * + * @internal + */ + private listboxInitialized: boolean = false; + /** * Updates properties on the listbox element when the listbox reference changes. * @@ -220,8 +234,11 @@ export class BaseDropdown extends FASTElement { public listboxChanged(prev: Listbox | undefined, next: Listbox | undefined): void { if (prev) { Observable.getNotifier(this).unsubscribe(prev); + Observable.getNotifier(prev).unsubscribe(this, 'options'); } + this.listboxInitialized = false; + if (next) { next.dropdown = this; next.popover = 'manual'; @@ -229,6 +246,7 @@ export class BaseDropdown extends FASTElement { const notifier = Observable.getNotifier(this); notifier.subscribe(next); + Observable.getNotifier(next).subscribe(this, 'options'); notifier.notify('multiple'); @@ -246,6 +264,9 @@ export class BaseDropdown extends FASTElement { x.selected = this.multiple || i === 0; }); + this.listboxInitialized = true; + this.applyPendingValue(); + this.setValidity(); }, { idleCallback: true }, @@ -261,6 +282,45 @@ export class BaseDropdown extends FASTElement { } } + /** + * Handles observable subscriptions. + * + * @param source - The source of the observed change. + * @param propertyName - The name of the property that changed. + * + * @internal + */ + public handleChange(source: any, propertyName?: string): void { + if (source === this.listbox && propertyName === 'options' && this.listboxInitialized) { + this.applyPendingValue(); + } + } + + private applyValue(next: string | null): void { + this.selectOption(this.enabledOptions.findIndex(x => x.value === next)); + Observable.track(this, 'value'); + } + + private applyPendingValue(): void { + if (this.pendingValue === undefined) { + return; + } + + const pendingValue = this.pendingValue; + this.pendingValue = undefined; + + if (this.multiple) { + return; + } + + if (!this.listbox?.options) { + this.pendingValue = pendingValue; + return; + } + + this.applyValue(pendingValue); + } + /** * Indicates whether the dropdown allows multiple options to be selected. * @@ -598,8 +658,13 @@ export class BaseDropdown extends FASTElement { if (this.multiple) { return; } - this.selectOption(this.enabledOptions.findIndex(x => x.value === next)); - Observable.track(this, 'value'); + + if (!this.listbox?.options) { + this.pendingValue = next; + return; + } + + this.applyValue(next); } /** @@ -945,6 +1010,10 @@ export class BaseDropdown extends FASTElement { * @public */ public selectOption(index: number = this.selectedIndex, shouldEmit: boolean = false): void { + if (!this.listbox) { + return; + } + this.listbox.selectOption(index); if (this.control) { this.control.value = this.displayValue; diff --git a/packages/web-components/src/dropdown/dropdown.spec.ts b/packages/web-components/src/dropdown/dropdown.spec.ts index dca5c0a00159ce..6195b6d91be9af 100644 --- a/packages/web-components/src/dropdown/dropdown.spec.ts +++ b/packages/web-components/src/dropdown/dropdown.spec.ts @@ -55,6 +55,146 @@ test.describe('Dropdown', () => { await expect(button).toHaveCount(1); }); + test('should apply a value set before the listbox is initialized', async ({ page, fastPage }) => { + await fastPage.setTemplate(''); + + await page.evaluate(() => { + const dropdown = document.createElement('fluent-dropdown') as Dropdown; + + dropdown.value = 'banana'; + + const listbox = document.createElement('fluent-listbox'); + listbox.innerHTML = /* html */ ` + Apple + Banana + Orange + `; + + dropdown.append(listbox); + document.body.append(dropdown); + }); + + await page.waitForFunction(() => { + const dropdown = document.querySelector('fluent-dropdown') as Dropdown | null; + + return dropdown?.selectedOptions[0]?.value === 'banana' && dropdown.value === 'banana'; + }); + + const value = await page.locator('fluent-dropdown').evaluate((dropdown: Dropdown) => ({ + selectedOptionValue: dropdown.selectedOptions[0]?.value, + value: dropdown.value, + })); + + expect(value).toEqual({ selectedOptionValue: 'banana', value: 'banana' }); + }); + + test('should apply a null value set before the listbox is initialized', async ({ page, fastPage }) => { + await fastPage.setTemplate(''); + + await page.evaluate(() => { + const dropdown = document.createElement('fluent-dropdown') as Dropdown; + + dropdown.value = null; + + const listbox = document.createElement('fluent-listbox'); + listbox.innerHTML = /* html */ ` + Apple + Banana + Orange + `; + + dropdown.append(listbox); + document.body.append(dropdown); + }); + + await page.waitForFunction(() => { + const dropdown = document.querySelector('fluent-dropdown') as Dropdown | null; + + return dropdown?.options.length === 3 && dropdown.selectedOptions.length === 0 && dropdown.value === null; + }); + + const value = await page.locator('fluent-dropdown').evaluate((dropdown: Dropdown) => ({ + selectedOptionsLength: dropdown.selectedOptions.length, + value: dropdown.value, + })); + + expect(value).toEqual({ selectedOptionsLength: 0, value: null }); + }); + + test('should ignore a value set before the listbox is initialized when multiple is true', async ({ + page, + fastPage, + }) => { + await fastPage.setTemplate(''); + + await page.evaluate(() => { + const dropdown = document.createElement('fluent-dropdown') as Dropdown; + + dropdown.multiple = true; + dropdown.value = 'banana'; + + const listbox = document.createElement('fluent-listbox'); + listbox.innerHTML = /* html */ ` + Apple + Banana + Orange + `; + + dropdown.append(listbox); + document.body.append(dropdown); + }); + + await page.waitForFunction(() => { + const dropdown = document.querySelector('fluent-dropdown') as Dropdown | null; + + return dropdown?.options.length === 3 && dropdown.selectedOptions.length === 0 && dropdown.value === null; + }); + + const value = await page.locator('fluent-dropdown').evaluate((dropdown: Dropdown) => ({ + selectedOptionsLength: dropdown.selectedOptions.length, + value: dropdown.value, + })); + + expect(value).toEqual({ selectedOptionsLength: 0, value: null }); + }); + + test('should display comma-separated selected options when multiple options are initially selected', async ({ + fastPage, + }) => { + const { element } = fastPage; + const button = element.locator('button'); + + await fastPage.setTemplate(/* html */ ` + + + Option 1 + Option 2 (Selectable) + Option 3 (Selectable) + + + `); + + await expect(button).toHaveText('Option 2 (Selectable), Option 3 (Selectable)'); + }); + + test('should update the comma-separated selected options display when selecting multiple options', async ({ + fastPage, + }) => { + const { element } = fastPage; + const button = element.locator('button'); + + await fastPage.setTemplate({ attributes: { multiple: true } }); + + await element.click(); + await element.locator('fluent-option[value=banana]').click(); + + await expect(button).toHaveText('Banana'); + + await element.locator('fluent-option[value=orange]').click(); + + await expect(button).toHaveText('Banana, Orange'); + }); + test('should render an input when the type attribute is set to "combobox"', async ({ fastPage }) => { const { element } = fastPage; const input = element.locator('input'); From 81b081b84234a5f2f39766e29509991f9197d526 Mon Sep 17 00:00:00 2001 From: Brian Brady Date: Mon, 11 May 2026 14:01:05 -0700 Subject: [PATCH 2/4] revert package.json --- packages/web-components/package.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/web-components/package.json b/packages/web-components/package.json index adbd62002830cb..cbed415018f1e3 100644 --- a/packages/web-components/package.json +++ b/packages/web-components/package.json @@ -78,15 +78,14 @@ "@custom-elements-manifest/analyzer": "0.10.10", "@microsoft/fast-element": "2.0.0", "@microsoft/focusgroup-polyfill": "^1.4.1", + "@tensile-perf/web-components": "~0.2.2", "@storybook/html": "9.1.17", "@storybook/html-vite": "9.1.17", - "@tensile-perf/web-components": "~0.2.2", - "@wc-toolkit/cem-inheritance": "1.2.2", "@wc-toolkit/cem-validator": "1.0.3", + "@wc-toolkit/cem-inheritance": "1.2.2", "@wc-toolkit/module-path-resolver": "1.0.0", "@wc-toolkit/type-parser": "1.0.3", - "chromedriver": "^125.0.0", - "storybook": "9.1.17" + "chromedriver": "^125.0.0" }, "dependencies": { "@fluentui/tokens": "1.0.0-alpha.23", From 8a7e6631b3b35a891ec13c35189649b8f5e654a2 Mon Sep 17 00:00:00 2001 From: Brian Brady Date: Mon, 11 May 2026 14:03:11 -0700 Subject: [PATCH 3/4] adds tsdocs --- packages/web-components/src/dropdown/dropdown.base.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/web-components/src/dropdown/dropdown.base.ts b/packages/web-components/src/dropdown/dropdown.base.ts index a605edfd740488..ca57c917d67725 100644 --- a/packages/web-components/src/dropdown/dropdown.base.ts +++ b/packages/web-components/src/dropdown/dropdown.base.ts @@ -296,11 +296,19 @@ export class BaseDropdown extends FASTElement { } } + /** + * Applies a single-select value to the listbox and tracks the value observable. + * + * @param next - The value to apply. + */ private applyValue(next: string | null): void { this.selectOption(this.enabledOptions.findIndex(x => x.value === next)); Observable.track(this, 'value'); } + /** + * Applies the pending value after the listbox and options are initialized. + */ private applyPendingValue(): void { if (this.pendingValue === undefined) { return; From 19e850bfae8d2742c450ecd907ee3f570bb843c3 Mon Sep 17 00:00:00 2001 From: Brian Brady Date: Mon, 11 May 2026 14:10:32 -0700 Subject: [PATCH 4/4] ending values only apply after the listbox has completed initialization --- .../src/dropdown/dropdown.base.ts | 11 ++++- .../src/dropdown/dropdown.spec.ts | 44 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/web-components/src/dropdown/dropdown.base.ts b/packages/web-components/src/dropdown/dropdown.base.ts index ca57c917d67725..2b349afe832c49 100644 --- a/packages/web-components/src/dropdown/dropdown.base.ts +++ b/packages/web-components/src/dropdown/dropdown.base.ts @@ -296,6 +296,13 @@ export class BaseDropdown extends FASTElement { } } + /** + * Determines whether the listbox is ready to apply a pending value. + */ + private canApplyValue(): boolean { + return this.listboxInitialized && !!this.listbox?.options?.length; + } + /** * Applies a single-select value to the listbox and tracks the value observable. * @@ -321,7 +328,7 @@ export class BaseDropdown extends FASTElement { return; } - if (!this.listbox?.options) { + if (!this.canApplyValue()) { this.pendingValue = pendingValue; return; } @@ -667,7 +674,7 @@ export class BaseDropdown extends FASTElement { return; } - if (!this.listbox?.options) { + if (!this.canApplyValue()) { this.pendingValue = next; return; } diff --git a/packages/web-components/src/dropdown/dropdown.spec.ts b/packages/web-components/src/dropdown/dropdown.spec.ts index 6195b6d91be9af..f6e9eb9413d5d4 100644 --- a/packages/web-components/src/dropdown/dropdown.spec.ts +++ b/packages/web-components/src/dropdown/dropdown.spec.ts @@ -88,6 +88,50 @@ test.describe('Dropdown', () => { expect(value).toEqual({ selectedOptionValue: 'banana', value: 'banana' }); }); + test('should apply a value set after the listbox is initialized but before options are initialized', async ({ + page, + fastPage, + }) => { + await fastPage.setTemplate(''); + + await page.evaluate(() => { + const dropdown = document.createElement('fluent-dropdown') as Dropdown; + const listbox = document.createElement('fluent-listbox'); + + dropdown.append(listbox); + document.body.append(dropdown); + }); + + await page.waitForFunction(() => { + const dropdown = document.querySelector('fluent-dropdown') as Dropdown | null; + + return dropdown?.listbox && dropdown.options.length === 0; + }); + + await page.locator('fluent-dropdown').evaluate((dropdown: Dropdown) => { + dropdown.value = 'banana'; + + dropdown.listbox.innerHTML = /* html */ ` + Apple + Banana + Orange + `; + }); + + await page.waitForFunction(() => { + const dropdown = document.querySelector('fluent-dropdown') as Dropdown | null; + + return dropdown?.selectedOptions[0]?.value === 'banana' && dropdown.value === 'banana'; + }); + + const value = await page.locator('fluent-dropdown').evaluate((dropdown: Dropdown) => ({ + selectedOptionValue: dropdown.selectedOptions[0]?.value, + value: dropdown.value, + })); + + expect(value).toEqual({ selectedOptionValue: 'banana', value: 'banana' }); + }); + test('should apply a null value set before the listbox is initialized', async ({ page, fastPage }) => { await fastPage.setTemplate('');