From 571b8c6c0e1bed20beed7c780f070d97ac7c96d8 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:44:40 -0700 Subject: [PATCH 01/45] fix(anchor-button): ensure tabIndex is set when connected --- .../web-components/src/anchor-button/anchor-button.base.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/web-components/src/anchor-button/anchor-button.base.ts b/packages/web-components/src/anchor-button/anchor-button.base.ts index 8c6757d254a8a..1cb7ebf1295cf 100644 --- a/packages/web-components/src/anchor-button/anchor-button.base.ts +++ b/packages/web-components/src/anchor-button/anchor-button.base.ts @@ -131,6 +131,9 @@ export class BaseAnchor extends FASTElement { public connectedCallback() { super.connectedCallback(); + + this.tabIndex = Number(this.getAttribute('tabindex') ?? 0) < 0 ? -1 : 0; + Observable.getNotifier(this).subscribe(this); Object.keys(this.$fastController.definition.attributeLookup).forEach(key => { From b2956d85ae9e09effcf08a6f13d78734e0a77bfa Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:45:59 -0700 Subject: [PATCH 02/45] fix(avatar): move initials generation out of template --- .../web-components/src/avatar/avatar.base.ts | 147 +++++++++++++----- .../src/avatar/avatar.styles.ts | 23 ++- .../src/avatar/avatar.template.ts | 7 +- 3 files changed, 136 insertions(+), 41 deletions(-) diff --git a/packages/web-components/src/avatar/avatar.base.ts b/packages/web-components/src/avatar/avatar.base.ts index 2a716e1fe459c..e75d1a238649d 100644 --- a/packages/web-components/src/avatar/avatar.base.ts +++ b/packages/web-components/src/avatar/avatar.base.ts @@ -1,4 +1,5 @@ -import { attr, FASTElement, Updates } from '@microsoft/fast-element'; +import { attr, FASTElement, observable, Updates } from '@microsoft/fast-element'; +import { getInitials } from '../utils/get-initials.js'; /** * The base class used for constructing a fluent-avatar custom element @@ -6,17 +7,80 @@ import { attr, FASTElement, Updates } from '@microsoft/fast-element'; */ export class BaseAvatar extends FASTElement { /** - * Signal to remove event listeners when the component is disconnected. + * Reference to the default slot element. + * @internal + */ + @observable + public defaultSlot!: HTMLSlotElement; + + /** + * Handles changes to the default slot element reference. + * + * Toggles the `has-slotted` class on the slot element for browsers that do not + * support the `:has-slotted` CSS selector. Defers cleanup using + * `Updates.enqueue` to avoid DOM mutations during hydration that could + * corrupt binding markers. * * @internal */ - private abortSignal?: AbortController; + public defaultSlotChanged() { + if (!CSS.supports('selector(:has-slotted)')) { + const elements = this.defaultSlot.assignedElements(); + this.defaultSlot.classList.toggle('has-slotted', elements.length > 0); + } + + // Defer cleanup to avoid DOM mutation (normalize) + // during hydration, which would corrupt binding markers. + Updates.enqueue(() => { + this.cleanupSlottedContent(); + }); + } /** - * Reference to the default slot element. + * Reference to the monogram element that displays generated initials. + * + * Uses a `ref` binding so the text content can be imperatively updated + * after hydration, avoiding calls to `generateInitials()` during SSR + * (which depends on runtime APIs like `getComputedStyle`). + * * @internal */ - public defaultSlot!: HTMLSlotElement; + @observable + public monogram!: HTMLElement; + + /** + * Handles changes to the monogram element reference. + * Updates the monogram text content when the ref is captured. + * + * @internal + */ + protected monogramChanged() { + this.updateMonogram(); + } + + /** + * The slotted content nodes assigned to the default slot. + * + * @internal + */ + @observable + public slottedDefaults: Node[] = []; + + /** + * Handles changes to the slotted default content. + * + * Normalizes the DOM, toggles the `has-slotted` class on the default slot element + * for browsers that do not support the `:has-slotted` CSS selector, and removes + * empty text nodes from the default slot to keep the DOM clean. + * + * @internal + */ + protected slottedDefaultsChanged() { + if (!this.defaultSlot) { + return; + } + this.cleanupSlottedContent(); + } /** * The internal {@link https://developer.mozilla.org/docs/Web/API/ElementInternals | `ElementInternals`} instance for the component. @@ -35,19 +99,30 @@ export class BaseAvatar extends FASTElement { @attr public name?: string | undefined; + /** + * Handles changes to the name attribute. + * @internal + */ + protected nameChanged() { + this.updateMonogram(); + } + /** * Provide custom initials rather than one generated via the name * * @public * @remarks - * HTML Attribute: name + * HTML Attribute: initials */ @attr public initials?: string | undefined; - connectedCallback(): void { - super.connectedCallback(); - this.slotchangeHandler(); + /** + * Handles changes to the initials attribute. + * @internal + */ + protected initialsChanged() { + this.updateMonogram(); } constructor() { @@ -56,43 +131,45 @@ export class BaseAvatar extends FASTElement { this.elementInternals.role = 'img'; } - disconnectedCallback(): void { - this.abortSignal?.abort(); - this.abortSignal = undefined; + /** + * Generates and sets the initials for the template. + * Subclasses should override this to provide custom initials logic. + * + * @internal + */ + public generateInitials(): string | void { + return this.initials || getInitials(this.name, window.getComputedStyle(this).direction === 'rtl', {}); + } - super.disconnectedCallback(); + /** + * Updates the monogram element's text content with the generated initials. + * + * @internal + */ + protected updateMonogram(): void { + if (this.monogram) { + this.monogram.textContent = this.generateInitials() ?? ''; + } } /** - * Removes any empty text nodes from the default slot when the slotted content changes. + * Normalizes the DOM and removes empty text nodes from the default slot. * - * @param e - The event object * @internal */ - public slotchangeHandler(): void { + protected cleanupSlottedContent(): void { this.normalize(); - const elements = this.defaultSlot.assignedElements(); - - if (!elements.length && !this.innerText.trim()) { - const nodes = this.defaultSlot.assignedNodes() as Element[]; - - nodes - .filter(node => node.nodeType === Node.TEXT_NODE) - .forEach(node => { - this.removeChild(node); - }); + if (!CSS.supports('selector(:has-slotted)')) { + this.defaultSlot.classList.toggle('has-slotted', !!this.slottedDefaults.length); } - Updates.enqueue(() => { - if (!this.abortSignal || this.abortSignal.signal.aborted) { - this.abortSignal = new AbortController(); - } - - this.defaultSlot.addEventListener('slotchange', () => this.slotchangeHandler(), { - once: true, - signal: this.abortSignal.signal, + if (!this.innerText.trim()) { + this.slottedDefaults.forEach(node => { + if (node.nodeType === Node.TEXT_NODE) { + (node as ChildNode).remove(); + } }); - }); + } } } diff --git a/packages/web-components/src/avatar/avatar.styles.ts b/packages/web-components/src/avatar/avatar.styles.ts index 40ec038dcffa9..16acc17d50973 100644 --- a/packages/web-components/src/avatar/avatar.styles.ts +++ b/packages/web-components/src/avatar/avatar.styles.ts @@ -118,10 +118,11 @@ const animations = { * @public */ export const styles = css` - ${display('inline-flex')} :host { + ${display('inline-grid')} :host { position: relative; - align-items: center; - justify-content: center; + place-items: center; + place-content: center; + grid-template: 1fr / 1fr; flex-shrink: 0; width: 32px; height: 32px; @@ -134,6 +135,22 @@ export const styles = css` contain: layout style; } + .monogram, + .default-icon { + grid-area: 1 / 1 / -1 / -1; + } + + .monogram:empty { + display: none; + } + + .default-slot:is(.has-slotted, :has-slotted) ~ .default-icon, + .default-slot:is(.has-slotted, :has-slotted) ~ .monogram, + :host(:is([name]):not([name=''])) .default-icon, + :host(:is([initials]):not([initials=''])) .default-icon { + display: none; + } + .default-icon, ::slotted(svg) { width: 20px; diff --git a/packages/web-components/src/avatar/avatar.template.ts b/packages/web-components/src/avatar/avatar.template.ts index 44cddcfa21f6b..afba79149450a 100644 --- a/packages/web-components/src/avatar/avatar.template.ts +++ b/packages/web-components/src/avatar/avatar.template.ts @@ -1,11 +1,10 @@ -import { type ElementViewTemplate, html, ref } from '@microsoft/fast-element'; +import { type ElementViewTemplate, html, ref, slotted } from '@microsoft/fast-element'; import type { Avatar } from './avatar.js'; const defaultIconTemplate = html`${x => x.initials} + ${defaultIconTemplate} `; } From 8866c4fe475dfddb768fbb46a3709e01e2328ac2 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:48:27 -0700 Subject: [PATCH 03/45] refactor(badge): streamline tests for appearance, color, size, and shape properties --- .../web-components/src/badge/badge.spec.ts | 74 +++++++++---------- 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/packages/web-components/src/badge/badge.spec.ts b/packages/web-components/src/badge/badge.spec.ts index 890529ddf2877..4c84a7271f978 100644 --- a/packages/web-components/src/badge/badge.spec.ts +++ b/packages/web-components/src/badge/badge.spec.ts @@ -26,6 +26,8 @@ test.describe('Badge', () => { test('should set default attribute values', async ({ fastPage }) => { const { element } = fastPage; + await fastPage.setTemplate(); + await expect(element).toHaveAttribute('appearance', 'filled'); await expect(element).toHaveJSProperty('appearance', 'filled'); @@ -35,59 +37,51 @@ test.describe('Badge', () => { await expect(element).toHaveJSProperty('color', 'brand'); }); - test('should set the `appearance` property to match the `appearance` attribute', async ({ fastPage }) => { - const { element } = fastPage; + for (const appearance of Object.values(BadgeAppearance)) { + test(`should set the \`appearance\` property to \`${appearance}\``, async ({ fastPage }) => { + const { element } = fastPage; - for (const appearance of Object.values(BadgeAppearance)) { - await test.step(appearance, async () => { - await fastPage.setTemplate({ attributes: { appearance } }); + await fastPage.setTemplate({ attributes: { appearance } }); - await expect(element).toHaveJSProperty('appearance', appearance); + await expect(element).toHaveJSProperty('appearance', appearance); - await expect(element).toHaveAttribute('appearance', appearance); - }); - } - }); + await expect(element).toHaveAttribute('appearance', appearance); + }); + } - test('should set the `color` property to match the `color` attribute', async ({ fastPage }) => { - const { element } = fastPage; + for (const color of Object.values(BadgeColor)) { + test(`should set the \`color\` property to \`${color}\``, async ({ fastPage }) => { + const { element } = fastPage; - for (const color of Object.values(BadgeColor)) { - await test.step(`should set the \`color\` property to \`${color}\``, async () => { - await fastPage.setTemplate({ attributes: { color } }); + await fastPage.setTemplate({ attributes: { color } }); - await expect(element).toHaveAttribute('color', color); + await expect(element).toHaveAttribute('color', color); - await expect(element).toHaveJSProperty('color', color); - }); - } - }); + await expect(element).toHaveJSProperty('color', color); + }); + } - test('should set the `size` property to match the `size` attribute', async ({ fastPage }) => { - const { element } = fastPage; + for (const size of Object.values(BadgeSize)) { + test(`should set the \`size\` property to \`${size}\``, async ({ fastPage }) => { + const { element } = fastPage; - for (const size of Object.values(BadgeSize)) { - await test.step(`should set the \`size\` property to \`${size}\``, async () => { - await fastPage.setTemplate({ attributes: { size } }); + await fastPage.setTemplate({ attributes: { size } }); - await expect(element).toHaveAttribute('size', size); + await expect(element).toHaveAttribute('size', size); - await expect(element).toHaveJSProperty('size', size); - }); - } - }); + await expect(element).toHaveJSProperty('size', size); + }); + } - test('should set the `shape` property to match the `shape` attribute', async ({ fastPage }) => { - const { element } = fastPage; + for (const shape of Object.values(BadgeShape)) { + test(`should set the \`shape\` property to \`${shape}\``, async ({ fastPage }) => { + const { element } = fastPage; - for (const shape of Object.values(BadgeShape)) { - await test.step(`should set the \`shape\` property to \`${shape}\``, async () => { - await fastPage.setTemplate({ attributes: { shape } }); + await fastPage.setTemplate({ attributes: { shape } }); - await expect(element).toHaveAttribute('shape', shape); + await expect(element).toHaveAttribute('shape', shape); - await expect(element).toHaveJSProperty('shape', shape); - }); - } - }); + await expect(element).toHaveJSProperty('shape', shape); + }); + } }); From cdb0c089ef643f7d6ace8ab9365d4e6cc6292f19 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:56:05 -0700 Subject: [PATCH 04/45] fix(button): update buttonTemplate to use BaseButton type --- packages/web-components/src/button/button.template.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web-components/src/button/button.template.ts b/packages/web-components/src/button/button.template.ts index 50248b9c26859..6188ba565352a 100644 --- a/packages/web-components/src/button/button.template.ts +++ b/packages/web-components/src/button/button.template.ts @@ -1,6 +1,6 @@ import { type ElementViewTemplate, html, slotted } from '@microsoft/fast-element'; import { endSlotTemplate, startSlotTemplate } from '../patterns/index.js'; -import type { Button } from './button.js'; +import type { BaseButton } from './button.base.js'; import type { ButtonOptions } from './button.options.js'; /** @@ -8,7 +8,7 @@ import type { ButtonOptions } from './button.options.js'; * * @public */ -export function buttonTemplate(options: ButtonOptions = {}): ElementViewTemplate { +export function buttonTemplate(options: ButtonOptions = {}): ElementViewTemplate { return html` `; } diff --git a/packages/web-components/src/radio-group/radio-group.ts b/packages/web-components/src/radio-group/radio-group.ts index b9b63f4945832..b58a4c4074c1d 100644 --- a/packages/web-components/src/radio-group/radio-group.ts +++ b/packages/web-components/src/radio-group/radio-group.ts @@ -1,6 +1,7 @@ import { attr, FASTElement, Observable, observable, Updates } from '@microsoft/fast-element'; import { findLastIndex } from '@microsoft/fast-web-utilities'; import { Radio } from '../radio/radio.js'; +import { isRadio } from '../radio/radio.options.js'; import { getDirection } from '../utils/direction.js'; import { getRootActiveElement } from '../utils/root-active-element.js'; import { RadioGroupOrientation } from './radio-group.options.js'; @@ -52,7 +53,7 @@ export class RadioGroup extends FASTElement { * HTML Attribute: `disabled` */ @attr({ attribute: 'disabled', mode: 'boolean' }) - public disabled: boolean = false; + public disabled!: boolean; /** * Sets the `disabled` attribute on all child radios when the `disabled` property changes. @@ -62,13 +63,13 @@ export class RadioGroup extends FASTElement { * @internal */ protected disabledChanged(prev?: boolean, next?: boolean): void { - if (this.$fastController.isConnected) { + requestAnimationFrame(() => { this.checkedIndex = -1; this.radios?.forEach(radio => { radio.disabled = !!radio.disabledAttribute || !!this.disabled; }); this.restrictFocus(); - } + }); } /** @@ -225,6 +226,24 @@ export class RadioGroup extends FASTElement { this.setValidity(); } + /** + * The collection of radios that are slotted into the default slot. + * + * @internal + */ + @observable + slottedRadios!: Radio[]; + + /** + * Updates the radios collection when the slotted radios change. + * + * @param prev - the previous slotted radios + * @param next - the current slotted radios + */ + slottedRadiosChanged(prev: Radio[] | undefined, next: Radio[]): void { + this.radios = [...this.querySelectorAll('*')].filter(x => isRadio(x)) as Radio[]; + } + /** * The internal {@link https://developer.mozilla.org/docs/Web/API/ElementInternals | `ElementInternals`} instance for the component. * @@ -606,16 +625,4 @@ export class RadioGroup extends FASTElement { }); } } - - /** - * Updates the collection of child radios when the slot changes. - * - * @param e - the slot change event - * @internal - */ - public slotchangeHandler(e: Event): void { - Updates.enqueue(() => { - this.radios = [...this.querySelectorAll('*')].filter(x => x instanceof Radio) as Radio[]; - }); - } } From beb763a1bffce74bfaeace66fab52153c3d75b8e Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:14:13 -0700 Subject: [PATCH 18/45] fix(rating-display): add observable for iconSlot and handle slot changes --- .../src/rating-display/rating-display.base.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/web-components/src/rating-display/rating-display.base.ts b/packages/web-components/src/rating-display/rating-display.base.ts index 760d7b764cd74..a6b7e0df27c60 100644 --- a/packages/web-components/src/rating-display/rating-display.base.ts +++ b/packages/web-components/src/rating-display/rating-display.base.ts @@ -1,4 +1,4 @@ -import { attr, FASTElement, nullableNumberConverter } from '@microsoft/fast-element'; +import { attr, FASTElement, nullableNumberConverter, observable } from '@microsoft/fast-element'; const SUPPORTS_ATTR_TYPE = CSS.supports('width: attr(value type())'); const CUSTOM_PROPERTY_NAME = { @@ -35,8 +35,13 @@ export class BaseRatingDisplay extends FASTElement { public elementInternals: ElementInternals = this.attachInternals(); /** @internal */ + @observable public iconSlot!: HTMLSlotElement; + iconSlotChanged() { + this.handleSlotChange(); + } + protected defaultCustomIconViewBox = '0 0 20 20'; /** @@ -151,16 +156,18 @@ export class BaseRatingDisplay extends FASTElement { } protected setCustomPropertyValue(propertyName: PropertyNameForCalculation) { - if (!this.display || SUPPORTS_ATTR_TYPE) { - return; - } - - const propertyValue = this[propertyName]; - - if (typeof propertyValue !== 'number' || Number.isNaN(propertyValue)) { - this.display.style.removeProperty(CUSTOM_PROPERTY_NAME[propertyName]); - } else { - this.display.style.setProperty(CUSTOM_PROPERTY_NAME[propertyName], `${propertyValue}`); - } + requestAnimationFrame(() => { + if (!this.display || SUPPORTS_ATTR_TYPE) { + return; + } + + const propertyValue = this[propertyName]; + + if (typeof propertyValue !== 'number' || Number.isNaN(propertyValue)) { + this.display.style.removeProperty(CUSTOM_PROPERTY_NAME[propertyName]); + } else { + this.display.style.setProperty(CUSTOM_PROPERTY_NAME[propertyName], `${propertyValue}`); + } + }); } } From 56119fd420146c29805378372786d040e3bfd8b5 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:14:30 -0700 Subject: [PATCH 19/45] test(slider): add test to prevent value change when dragging inside a disabled fieldset --- .../web-components/src/slider/slider.spec.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/web-components/src/slider/slider.spec.ts b/packages/web-components/src/slider/slider.spec.ts index c28fe629beed9..e49fa9e18ef10 100644 --- a/packages/web-components/src/slider/slider.spec.ts +++ b/packages/web-components/src/slider/slider.spec.ts @@ -809,6 +809,38 @@ test.describe('Slider', () => { expect(thumbBox.y + thumbBox.height / 2).toBeCloseTo(thumbMoveToY); } }); + + test('should not change value when dragging inside a disabled fieldset', async ({ fastPage, page }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+
+ +
+
+ `); + + const thumb = element.locator('.thumb-container'); + const track = element.locator('.track'); + const trackBox = (await track.boundingBox()) as BoundingBox; + + expect(trackBox).not.toBeNull(); + + const thumbBox = (await thumb.boundingBox()) as BoundingBox; + expect(thumbBox).not.toBeNull(); + + const thumbCenterX = thumbBox.x + thumbBox.width / 2; + const thumbCenterY = thumbBox.y + thumbBox.height / 2; + const thumbMoveToX = thumbCenterX - trackBox.width * 0.1; + + await page.mouse.move(thumbCenterX, thumbCenterY); + await page.mouse.down(); + await page.mouse.move(thumbMoveToX, thumbCenterY); + await page.mouse.up(); + + await expect(element).toHaveJSProperty('valueAsNumber', 50); + }); }); test('should allow keyboard interactions after clicking on the thumb', async ({ fastPage, page }) => { From a9f1dcd40a2133b051ab5bfac1b7b603d87502eb Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:15:20 -0700 Subject: [PATCH 20/45] fix(slider): improve disabled state handling and update related methods --- packages/web-components/src/slider/slider.ts | 85 ++++++++++++-------- 1 file changed, 51 insertions(+), 34 deletions(-) diff --git a/packages/web-components/src/slider/slider.ts b/packages/web-components/src/slider/slider.ts index 9bc4504dee953..89643113fb671 100644 --- a/packages/web-components/src/slider/slider.ts +++ b/packages/web-components/src/slider/slider.ts @@ -1,5 +1,5 @@ -import { attr, css, FASTElement, observable, Observable } from '@microsoft/fast-element'; import type { ElementStyles } from '@microsoft/fast-element'; +import { attr, css, FASTElement, observable, Observable } from '@microsoft/fast-element'; import { Direction, keyArrowDown, @@ -13,9 +13,8 @@ import { } from '@microsoft/fast-web-utilities'; import { numberLikeStringConverter } from '../utils/converters.js'; import { getDirection } from '../utils/direction.js'; -import { swapStates } from '../utils/element-internals.js'; -import { type SliderConfiguration, SliderMode, SliderOrientation, SliderSize } from './slider.options.js'; import { convertPixelToPercent } from './slider-utilities.js'; +import { type SliderConfiguration, SliderMode, SliderOrientation, SliderSize } from './slider.options.js'; /** * The base class used for constructing a fluent-slider custom element @@ -65,12 +64,31 @@ export class Slider extends FASTElement implements SliderConfiguration { public handleChange(_: any, propertyName: string): void { switch (propertyName) { + case 'isConnected': { + if (this.$fastController.isConnected) { + this.direction = getDirection(this); + + this.setDisabledSideEffect(this.disabled); + this.updateStepMultiplier(); + this.setupTrackConstraints(); + this.setupDefaultValue(); + this.setSliderPosition(); + + this.handleStepStyles(); + } + + break; + } + case 'min': - case 'max': + case 'max': { this.setSliderPosition(); - case 'step': + } + + case 'step': { this.handleStepStyles(); break; + } } } @@ -197,7 +215,7 @@ export class Slider extends FASTElement implements SliderConfiguration { */ public setValidity(flags?: Partial, message?: string, anchor?: HTMLElement): void { if (this.$fastController.isConnected) { - if (this.disabled) { + if (this.isDisabled) { this.elementInternals.setValidity({}); return; } @@ -388,6 +406,12 @@ export class Slider extends FASTElement implements SliderConfiguration { this.setDisabledSideEffect(this.disabled); } + protected get isDisabled() { + return ( + this.disabled || this.elementInternals?.ariaDisabled === 'true' || (this.isConnected && this.matches(':disabled')) + ); + } + /** * The minimum allowed value. * @@ -521,27 +545,17 @@ export class Slider extends FASTElement implements SliderConfiguration { this.elementInternals.role = 'slider'; this.elementInternals.ariaOrientation = this.orientation ?? SliderOrientation.horizontal; + + this.$fastController.subscribe(this, 'isConnected'); } - /** - * @internal - */ public connectedCallback(): void { super.connectedCallback(); - this.direction = getDirection(this); - - this.setDisabledSideEffect(this.disabled); - this.updateStepMultiplier(); - this.setupTrackConstraints(); - this.setupDefaultValue(); - this.setSliderPosition(); - - Observable.getNotifier(this).subscribe(this, 'max'); - Observable.getNotifier(this).subscribe(this, 'min'); - Observable.getNotifier(this).subscribe(this, 'step'); - - this.handleStepStyles(); + const notifier = Observable.getNotifier(this); + notifier.subscribe(this, 'max'); + notifier.subscribe(this, 'min'); + notifier.subscribe(this, 'step'); } /** @@ -550,9 +564,10 @@ export class Slider extends FASTElement implements SliderConfiguration { public disconnectedCallback(): void { super.disconnectedCallback(); - Observable.getNotifier(this).unsubscribe(this, 'max'); - Observable.getNotifier(this).unsubscribe(this, 'min'); - Observable.getNotifier(this).unsubscribe(this, 'step'); + const notifier = Observable.getNotifier(this); + notifier.unsubscribe(this, 'max'); + notifier.unsubscribe(this, 'min'); + notifier.unsubscribe(this, 'step'); } /** @@ -588,7 +603,7 @@ export class Slider extends FASTElement implements SliderConfiguration { } public handleKeydown(event: KeyboardEvent): boolean { - if (this.disabled) { + if (this.isDisabled) { return true; } @@ -686,6 +701,9 @@ export class Slider extends FASTElement implements SliderConfiguration { * If the event handler is null it removes the events */ public handleThumbPointerDown = (event: PointerEvent | null): boolean => { + if (this.isDisabled) { + return true; + } const windowFn = event !== null ? window.addEventListener : window.removeEventListener; windowFn('pointerup', this.handleWindowPointerUp); windowFn('pointermove', this.handlePointerMove, { passive: true }); @@ -699,7 +717,7 @@ export class Slider extends FASTElement implements SliderConfiguration { * Handle mouse moves during a thumb drag operation */ private handlePointerMove = (event: PointerEvent | TouchEvent | Event): void => { - if (this.disabled || event.defaultPrevented) { + if (this.isDisabled || event.defaultPrevented) { return; } @@ -756,7 +774,7 @@ export class Slider extends FASTElement implements SliderConfiguration { * @param event - PointerEvent or null. If there is no event handler it will remove the events */ public handlePointerDown = (event: PointerEvent | null) => { - if (event === null || !this.disabled) { + if (event === null || !this.isDisabled) { const windowFn = event !== null ? window.addEventListener : window.removeEventListener; const documentFn = event !== null ? document.addEventListener : document.removeEventListener; windowFn('pointerup', this.handleWindowPointerUp); @@ -804,11 +822,10 @@ export class Slider extends FASTElement implements SliderConfiguration { /** * Makes sure the side effects of set up when the disabled state changes. */ - private setDisabledSideEffect(disabled: boolean) { - if (!this.$fastController.isConnected) { - return; - } - this.elementInternals.ariaDisabled = disabled.toString(); - this.tabIndex = disabled ? -1 : 0; + private setDisabledSideEffect(disabled: boolean = this.isDisabled): void { + requestAnimationFrame(() => { + this.elementInternals.ariaDisabled = disabled.toString(); + this.tabIndex = disabled ? -1 : 0; + }); } } From 55a34cbf9db7065aa12b148d461758b9446499fa Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:16:07 -0700 Subject: [PATCH 21/45] feat(tab): enhance accessibility by setting role and aria attributes, and improve internal styles handling --- packages/web-components/src/tab/tab.spec.ts | 37 ++++++++++++++------- packages/web-components/src/tab/tab.ts | 30 +++++++++++++++-- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/packages/web-components/src/tab/tab.spec.ts b/packages/web-components/src/tab/tab.spec.ts index e2f3e3fe7bf20..2b46355e14709 100644 --- a/packages/web-components/src/tab/tab.spec.ts +++ b/packages/web-components/src/tab/tab.spec.ts @@ -1,11 +1,10 @@ import { expect, test } from '../../test/playwright/index.js'; -import type { Tab } from './tab.js'; test.describe('Tab', () => { test.use({ tagName: 'fluent-tab' }); test('should create with document.createElement()', async ({ page, fastPage }) => { - await fastPage.setTemplate(); + await fastPage.setTemplate(''); let hasError = false; @@ -20,25 +19,39 @@ test.describe('Tab', () => { expect(hasError).toBe(false); }); - test('should set defaults', async ({ fastPage }) => { + test('should have a role of `tab`', async ({ fastPage }) => { const { element } = fastPage; - await test.step('should have a role of `tab`', async () => { - await expect(element).toHaveAttribute('role', 'tab'); - }); + await fastPage.setTemplate(); - await test.step('should have a slot attribute of `tab`', async () => { - await expect(element).toHaveAttribute('slot', 'tab'); - }); + await expect(element).toHaveRole('tab'); }); - test('should set the `aria-disabled` attribute when `disabled` is true', async ({ fastPage }) => { + test('should have a slot attribute of `tab`', async ({ fastPage }) => { const { element } = fastPage; + await fastPage.setTemplate(); + + await expect(element).toHaveAttribute('slot', 'tab'); + }); + + test('should NOT set the `aria-disabled` attribute when `disabled` is not initially set', async ({ fastPage }) => { + const { element } = fastPage; + + await fastPage.setTemplate(); + await expect(element).not.toHaveAttribute('aria-disabled'); + }); + + test('should set the `aria-disabled` attribute when `disabled` is true during connectedCallback', async ({ + fastPage, + }) => { + const { element } = fastPage; - await element.evaluate((node: Tab) => { - node.disabled = true; + await fastPage.setTemplate({ + attributes: { + disabled: true, + }, }); await expect(element).toHaveAttribute('aria-disabled', 'true'); diff --git a/packages/web-components/src/tab/tab.ts b/packages/web-components/src/tab/tab.ts index d8368fa8840c8..4b3b4dd7c4a0b 100644 --- a/packages/web-components/src/tab/tab.ts +++ b/packages/web-components/src/tab/tab.ts @@ -24,16 +24,40 @@ export class Tab extends FASTElement { @attr({ mode: 'boolean' }) public disabled!: boolean; - private styles: ElementStyles | undefined; + /** + * Internal text content stylesheet, used to set the content of the `::after` + * pseudo element to prevent layout shift when the font weight changes on selection. + * @internal + */ + private styles?: ElementStyles; + + /** + * The internal {@link https://developer.mozilla.org/docs/Web/API/ElementInternals | `ElementInternals`} instance for the component. + * + * @internal + */ + public elementInternals: ElementInternals = this.attachInternals(); + + constructor() { + super(); + + this.elementInternals.role = 'tab'; + } connectedCallback() { super.connectedCallback(); - if (this.styles !== undefined) { + this.slot = this.slot || 'tab'; + + if (this.disabled) { + this.setAttribute('aria-disabled', 'true'); + } + + if (this.styles) { this.$fastController.removeStyles(this.styles); } - this.styles = css/**css*/ ` + this.styles = css` :host { --textContent: '${this.textContent as any}'; } From 3eb6e4beb1f9cde54949f5a99cb3f10a653ecdfd Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:01:43 -0700 Subject: [PATCH 22/45] fix(tablist): improve tabsChanged method to handle previous and next tab states --- .../src/tablist/tablist.base.ts | 69 ++++++++++++++----- .../web-components/src/tablist/tablist.ts | 4 +- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/packages/web-components/src/tablist/tablist.base.ts b/packages/web-components/src/tablist/tablist.base.ts index 8e69360116ab3..c4eee33dde081 100644 --- a/packages/web-components/src/tablist/tablist.base.ts +++ b/packages/web-components/src/tablist/tablist.base.ts @@ -9,11 +9,12 @@ import { uniqueId, wrapInBounds, } from '@microsoft/fast-web-utilities'; -import { getDirection } from '../utils/index.js'; -import { swapStates, toggleState } from '../utils/element-internals.js'; -import { isFocusableElement } from '../utils/focusable-element.js'; import type { Tab } from '../tab/tab.js'; import { isTab } from '../tab/tab.options.js'; +import { swapStates, toggleState } from '../utils/element-internals.js'; +import { isFocusableElement } from '../utils/focusable-element.js'; +import { getDirection } from '../utils/index.js'; +import { waitForConnectedDescendants } from '../utils/request-idle-callback.js'; import { TablistOrientation } from './tablist.options.js'; /** @@ -45,9 +46,7 @@ export class BaseTablist extends FASTElement { */ protected disabledChanged(prev: boolean, next: boolean): void { toggleState(this.elementInternals, 'disabled', next); - if (this.$fastController.isConnected) { - this.setTabs(); - } + this.setTabs(); } /** @@ -62,13 +61,12 @@ export class BaseTablist extends FASTElement { * @internal */ protected orientationChanged(prev: TablistOrientation, next: TablistOrientation): void { - this.elementInternals.ariaOrientation = next ?? TablistOrientation.horizontal; - - swapStates(this.elementInternals, prev, next, TablistOrientation); - - if (this.$fastController.isConnected) { - this.setTabs(); + if (this.elementInternals) { + this.elementInternals.ariaOrientation = next ?? TablistOrientation.horizontal; + swapStates(this.elementInternals, prev, next, TablistOrientation); } + + this.setTabs(); } /** @@ -84,7 +82,7 @@ export class BaseTablist extends FASTElement { * @internal */ protected activeidChanged(oldValue: string, newValue: string): void { - if (this.$fastController.isConnected && this.tabs.length > 0) { + if (this.tabs.length > 0) { this.prevActiveTabIndex = this.tabs.findIndex((item: HTMLElement) => item.id === oldValue); this.setTabs(); @@ -132,8 +130,15 @@ export class BaseTablist extends FASTElement { /** * @internal */ - protected tabsChanged(): void { - if (this.$fastController.isConnected && this.tabs.length > 0) { + protected tabsChanged(prev: Tab[] | undefined, next: Tab[] | undefined): void { + if (prev?.length) { + prev.forEach((tab: Tab) => { + tab.removeEventListener('click', this.handleTabClick); + tab.removeEventListener('keydown', this.handleTabKeyDown); + }); + } + + if (this.tabs.length > 0) { this.tabIds = this.getTabIds(); this.setTabs(); @@ -183,13 +188,12 @@ export class BaseTablist extends FASTElement { protected setTabs(): void { this.activeTabIndex = this.getActiveIndex(); - const hasStartSlot = this.tabs.some(tab => !!tab.querySelector("[slot='start']")); + const hasStartSlot = this.tabs?.some(tab => !!tab.querySelector("[slot='start']")); - this.tabs.forEach((tab: Tab, index: number) => { + this.tabs?.forEach((tab: Tab, index: number) => { if (tab.slot === 'tab') { const isActiveTab = this.activeTabIndex === index && isFocusableElement(tab); const tabId: string = this.tabIds[index]; - console.log('disabled', this.disabled); if (!tab.disabled) { this.disabled ? tab.setAttribute('aria-disabled', 'true') : tab.removeAttribute('aria-disabled'); } @@ -225,6 +229,9 @@ export class BaseTablist extends FASTElement { private handleTabClick = (event: MouseEvent): void => { const selectedTab = event.currentTarget as Tab; + if (selectedTab.disabled || this.disabled) { + return; + } if (selectedTab.nodeType === Node.ELEMENT_NODE && isFocusableElement(selectedTab)) { this.prevActiveTabIndex = this.activeTabIndex; this.activeTabIndex = this.tabs.indexOf(selectedTab); @@ -237,6 +244,10 @@ export class BaseTablist extends FASTElement { } private handleTabKeyDown = (event: KeyboardEvent): void => { + if (this.disabled) { + return; + } + const dir = getDirection(this); switch (event.key) { case keyArrowLeft: @@ -311,6 +322,28 @@ export class BaseTablist extends FASTElement { this.tabs[this.activeTabIndex].focus(); } + constructor() { + super(); + + this.elementInternals.role = 'tablist'; + this.elementInternals.ariaOrientation = this.orientation ?? TablistOrientation.horizontal; + + this.$fastController.subscribe( + { + handleChange: (source: any, propertyName: string) => { + if (propertyName === 'isConnected') { + waitForConnectedDescendants(this, () => { + requestAnimationFrame(() => { + this.setTabs(); + }); + }); + } + }, + }, + 'isConnected', + ); + } + /** * @internal */ diff --git a/packages/web-components/src/tablist/tablist.ts b/packages/web-components/src/tablist/tablist.ts index e00d98605c82d..f6b25d0c2fa3a 100644 --- a/packages/web-components/src/tablist/tablist.ts +++ b/packages/web-components/src/tablist/tablist.ts @@ -164,8 +164,8 @@ export class Tablist extends BaseTablist { /** * Initiates the active tab indicator animation loop when tabs change. */ - public tabsChanged(): void { - super.tabsChanged(); + public tabsChanged(prev: Tab[] | undefined, next: Tab[] | undefined): void { + super.tabsChanged(prev, next); this.setTabData(); if (this.activetab) { From e6d51ee0af03bc10cdef39927ae0a50ad73ca9e9 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:01:59 -0700 Subject: [PATCH 23/45] test(text): refactor tests to streamline property checks for block, size, weight, align, and font attributes --- packages/web-components/src/text/text.spec.ts | 86 +++++++++---------- 1 file changed, 42 insertions(+), 44 deletions(-) diff --git a/packages/web-components/src/text/text.spec.ts b/packages/web-components/src/text/text.spec.ts index 5733c96f0f2ad..1e8567164f29f 100644 --- a/packages/web-components/src/text/text.spec.ts +++ b/packages/web-components/src/text/text.spec.ts @@ -103,66 +103,64 @@ test.describe('Text Component', () => { await expect(element).toHaveJSProperty('block', true); - await test.step('should set the `block` property to false when the `block` attribute is removed', async () => { - await fastPage.setTemplate({ attributes: {} }); + await element.evaluate(node => node.removeAttribute('block')); - await expect(element).toHaveJSProperty('block', false); - }); + await expect(element).toHaveJSProperty('block', false); }); - test('should set the `size` property to match the `size` attribute', async ({ fastPage }) => { - const { element } = fastPage; + for (const size of Object.values(TextSize)) { + test(`should set the \`size\` property to \`${size}\` when the \`size\` attribute is \`${size}\``, async ({ + fastPage, + }) => { + const { element } = fastPage; - for (const size of Object.values(TextSize)) { - await test.step(size, async () => { - await fastPage.setTemplate({ attributes: { size } }); + await fastPage.setTemplate({ attributes: { size } }); - await expect(element).toHaveJSProperty('size', size); + await expect(element).toHaveJSProperty('size', size); - await expect(element).toHaveAttribute('size', size); - }); - } - }); + await expect(element).toHaveAttribute('size', size); + }); + } - test('should set the `weight` property to match the `weight` attribute', async ({ fastPage }) => { - const { element } = fastPage; + for (const weight of Object.values(TextWeight)) { + test(`should set the \`weight\` property to \`${weight}\` when the \`weight\` attribute is \`${weight}\``, async ({ + fastPage, + }) => { + const { element } = fastPage; - for (const weight of Object.values(TextWeight)) { - await test.step(weight, async () => { - await fastPage.setTemplate({ attributes: { weight } }); + await fastPage.setTemplate({ attributes: { weight } }); - await expect(element).toHaveJSProperty('weight', weight); + await expect(element).toHaveJSProperty('weight', weight); - await expect(element).toHaveAttribute('weight', weight); - }); - } - }); + await expect(element).toHaveAttribute('weight', weight); + }); + } - test('should set the `align` property to match the `align` attribute', async ({ fastPage }) => { - const { element } = fastPage; + for (const align of Object.values(TextAlign)) { + test(`should set the \`align\` property to \`${align}\` when the \`align\` attribute is \`${align}\``, async ({ + fastPage, + }) => { + const { element } = fastPage; - for (const align of Object.values(TextAlign)) { - await test.step(align, async () => { - await fastPage.setTemplate({ attributes: { align } }); + await fastPage.setTemplate({ attributes: { align } }); - await expect(element).toHaveJSProperty('align', align); + await expect(element).toHaveJSProperty('align', align); - await expect(element).toHaveAttribute('align', align); - }); - } - }); + await expect(element).toHaveAttribute('align', align); + }); + } - test('should set the `font` property to match the `font` attribute', async ({ fastPage }) => { - const { element } = fastPage; + for (const font of Object.values(TextFont)) { + test(`should set the \`font\` property to \`${font}\` when the \`font\` attribute is \`${font}\``, async ({ + fastPage, + }) => { + const { element } = fastPage; - for (const font of Object.values(TextFont)) { - await test.step(font, async () => { - await fastPage.setTemplate({ attributes: { font } }); + await fastPage.setTemplate({ attributes: { font } }); - await expect(element).toHaveJSProperty('font', font); + await expect(element).toHaveJSProperty('font', font); - await expect(element).toHaveAttribute('font', font); - }); - } - }); + await expect(element).toHaveAttribute('font', font); + }); + } }); From 721977a81d594a2a187b4eb597fea7a4b293f213 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:23:04 -0700 Subject: [PATCH 24/45] fix(text-input): enhance control label visibility and validity handling --- .../src/text-input/text-input.base.ts | 36 ++++++++++++++++--- .../src/text-input/text-input.spec.ts | 4 +-- .../src/text-input/text-input.template.ts | 13 ++----- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/packages/web-components/src/text-input/text-input.base.ts b/packages/web-components/src/text-input/text-input.base.ts index 54485149866a0..619041a65a975 100644 --- a/packages/web-components/src/text-input/text-input.base.ts +++ b/packages/web-components/src/text-input/text-input.base.ts @@ -1,4 +1,12 @@ -import { attr, FASTElement, nullableNumberConverter, Observable, observable } from '@microsoft/fast-element'; +import { + attr, + FASTElement, + nullableNumberConverter, + Observable, + observable, + type Subscriber, + Updates, +} from '@microsoft/fast-element'; import { ImplicitSubmissionBlockingTypes, TextInputType } from './text-input.options.js'; /** @@ -71,9 +79,11 @@ export class BaseTextInput extends FASTElement { * @internal */ public defaultSlottedNodesChanged(prev: Node[] | undefined, next: Node[] | undefined): void { - if (this.$fastController.isConnected) { - this.controlLabel.hidden = !next?.length; - } + Updates.enqueue(() => { + if (this.defaultSlottedNodes.every(node => node.nodeType === Node.TEXT_NODE)) { + this.controlLabel.hidden = this.innerText.trim().length === 0; + } + }); } /** @@ -304,7 +314,21 @@ export class BaseTextInput extends FASTElement { * @internal */ public controlChanged(prev: HTMLInputElement | undefined, next: HTMLInputElement | undefined): void { - this.setValidity(); + if (this.$fastController.isConnected) { + this.setValidity(); + return; + } + + const subscriber: Subscriber = { + handleChange: () => { + if (this.$fastController.isConnected) { + this.setValidity(); + this.$fastController.unsubscribe(subscriber, 'isConnected'); + } + }, + }; + + this.$fastController.subscribe(subscriber, 'isConnected'); } /** @@ -459,6 +483,8 @@ export class BaseTextInput extends FASTElement { public connectedCallback(): void { super.connectedCallback(); + this.tabIndex = Number(this.getAttribute('tabindex') ?? 0) < 0 ? -1 : 0; + this.setFormValue(this.value); this.setValidity(); } diff --git a/packages/web-components/src/text-input/text-input.spec.ts b/packages/web-components/src/text-input/text-input.spec.ts index 859e6adffb051..ecd1c66ccbc0c 100644 --- a/packages/web-components/src/text-input/text-input.spec.ts +++ b/packages/web-components/src/text-input/text-input.spec.ts @@ -678,8 +678,8 @@ test.describe('TextInput', () => { await fastPage.setTemplate({ innerHTML: /* html */ ` Label - - + + `, }); diff --git a/packages/web-components/src/text-input/text-input.template.ts b/packages/web-components/src/text-input/text-input.template.ts index 9d4b09ce8c416..0a46b61867df6 100644 --- a/packages/web-components/src/text-input/text-input.template.ts +++ b/packages/web-components/src/text-input/text-input.template.ts @@ -1,7 +1,5 @@ -import type { ElementViewTemplate } from '@microsoft/fast-element'; -import { html, ref, slotted } from '@microsoft/fast-element'; -import { endSlotTemplate, startSlotTemplate } from '../patterns/index.js'; -import { whitespaceFilter } from '../utils/index.js'; +import { type ElementViewTemplate, html, ref, slotted } from '@microsoft/fast-element'; +import { endSlotTemplate, startSlotTemplate } from '../patterns/start-end.js'; import type { TextInput } from './text-input.js'; import type { TextInputOptions } from './text-input.options.js'; @@ -18,12 +16,7 @@ export function textInputTemplate(options: TextInputOptions @keydown="${(x, c) => x.keydownHandler(c.event as KeyboardEvent)}" >
${startSlotTemplate(options)} From 9f804285035f0c2c6ab4ac95f6844e82b187bc8c Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:24:42 -0700 Subject: [PATCH 25/45] fix(textarea): improve connectedCallback handling --- .../src/textarea/textarea.base.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/web-components/src/textarea/textarea.base.ts b/packages/web-components/src/textarea/textarea.base.ts index 0bdf3014301ba..ac9c1f5511efe 100644 --- a/packages/web-components/src/textarea/textarea.base.ts +++ b/packages/web-components/src/textarea/textarea.base.ts @@ -244,6 +244,10 @@ export class BaseTextArea extends FASTElement { public readOnly = false; protected readOnlyChanged() { this.elementInternals.ariaReadOnly = `${!!this.readOnly}`; + + if (this.$fastController.isConnected) { + this.setValidity(); + } } /** @@ -399,11 +403,22 @@ export class BaseTextArea extends FASTElement { public connectedCallback(): void { super.connectedCallback(); - this.setDefaultValue(); - this.maybeCreateAutoSizerEl(); + requestAnimationFrame(() => { + const preConnect = this.preConnectControlEl; + const content = this.getContent(); - this.bindEvents(); - this.observeControlElAttrs(); + this.defaultValue = content || preConnect?.defaultValue || ''; + this.value = preConnect?.value || this.defaultValue; + this.setFormValue(this.value); + this.setValidity(); + this.preConnectControlEl = null; + + this.maybeCreateAutoSizerEl(); + + this.bindEvents(); + + this.observeControlElAttrs(); + }); } /** From 7c10c5db6cbd1fd472e6cff86503df6613f3e733 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:25:10 -0700 Subject: [PATCH 26/45] fix(tree): enhance default slot handling and improve connectedCallback logic --- packages/web-components/src/tree/tree.base.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/web-components/src/tree/tree.base.ts b/packages/web-components/src/tree/tree.base.ts index 646cccdd70d1e..2b04c0f9e3840 100644 --- a/packages/web-components/src/tree/tree.base.ts +++ b/packages/web-components/src/tree/tree.base.ts @@ -36,16 +36,34 @@ export class BaseTree extends FASTElement { public elementInternals: ElementInternals = this.attachInternals(); /** @internal */ + @observable public defaultSlot!: HTMLSlotElement; + /** + * Calls the slot change handler when the `defaultSlot` reference is updated + * by the template binding. + * + * @internal + */ + defaultSlotChanged() { + this.handleDefaultSlotChange(); + } + constructor() { super(); this.elementInternals.role = 'tree'; } + connectedCallback(): void { + super.connectedCallback(); + + this.tabIndex = Number(this.getAttribute('tabindex') ?? 0) < 0 ? -1 : 0; + } + /** @internal */ @observable - childTreeItems: BaseTreeItem[] = []; + childTreeItems!: BaseTreeItem[]; + /** @internal */ public childTreeItemsChanged() { this.updateCurrentSelected(); From 22a00da1aa6d67b0bb45f2b97a67e23c3c6412b8 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:25:33 -0700 Subject: [PATCH 27/45] fix(tree-item): enhance itemSlot change handling and improve tabindex update logic --- .../src/tree-item/tree-item.base.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/web-components/src/tree-item/tree-item.base.ts b/packages/web-components/src/tree-item/tree-item.base.ts index d13b15923ce3a..f5d6d6386017b 100644 --- a/packages/web-components/src/tree-item/tree-item.base.ts +++ b/packages/web-components/src/tree-item/tree-item.base.ts @@ -14,6 +14,16 @@ export class BaseTreeItem extends FASTElement { @observable public itemSlot!: HTMLSlotElement; + /** + * Calls the slot change handler when the `itemSlot` reference is updated + * by the template binding. + * + * @internal + */ + public itemSlotChanged() { + this.handleItemSlotChange(); + } + constructor() { super(); this.elementInternals.role = 'treeitem'; @@ -55,7 +65,7 @@ export class BaseTreeItem extends FASTElement { * HTML Attribute: selected */ @attr({ mode: 'boolean' }) - selected: boolean = false; + selected!: boolean; /** * Handles changes to the selected attribute @@ -122,7 +132,10 @@ export class BaseTreeItem extends FASTElement { connectedCallback() { super.connectedCallback(); - this.updateTabindexBySelected(); + + requestAnimationFrame(() => { + this.updateTabindexBySelected(); + }); } /** @@ -173,9 +186,7 @@ export class BaseTreeItem extends FASTElement { } protected updateTabindexBySelected() { - if (this.$fastController.isConnected) { - this.tabIndex = this.selected ? 0 : -1; - } + this.tabIndex = this.selected ? 0 : -1; } /** @internal */ From 36a19f251e791140ed6bcf276cf4f42790b37954 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:25:44 -0700 Subject: [PATCH 28/45] fix(focusable-element): enhance ARIA disabled element check to include elementInternals --- packages/web-components/src/utils/focusable-element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-components/src/utils/focusable-element.ts b/packages/web-components/src/utils/focusable-element.ts index acb8059c8f5ff..09cbfc381f6f3 100644 --- a/packages/web-components/src/utils/focusable-element.ts +++ b/packages/web-components/src/utils/focusable-element.ts @@ -1,5 +1,5 @@ export const isARIADisabledElement = (el: Element): boolean => { - return el.getAttribute('aria-disabled') === 'true'; + return el.getAttribute('aria-disabled') === 'true' || (el as any).elementInternals?.ariaDisabled === true; }; export const isHiddenElement = (el: Element): boolean => { From 6bc84e2fd00b2ed98d6e30e367e85d65c494d42b Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:25:56 -0700 Subject: [PATCH 29/45] fix(fast-fixture): enhance fixture options and stability handling for testing --- .../test/playwright/fast-fixture.ts | 168 +++++++++++++++--- 1 file changed, 148 insertions(+), 20 deletions(-) diff --git a/packages/web-components/test/playwright/fast-fixture.ts b/packages/web-components/test/playwright/fast-fixture.ts index 374b96bc07579..89369043eaede 100644 --- a/packages/web-components/test/playwright/fast-fixture.ts +++ b/packages/web-components/test/playwright/fast-fixture.ts @@ -1,5 +1,18 @@ import type { Locator, Page } from '@playwright/test'; +/** + * The options for configuring the fixture's template. + */ +export type FixtureOptions = { + attributes?: Record; + innerHTML?: string; +}; + +/** + * The template configuration, which can be a raw HTML string or fixture options. + */ +export type TemplateOrOptions = FixtureOptions | string; + /** * A fixture for testing FAST components. */ @@ -12,23 +25,62 @@ export class FASTFixture { /** * The tag name of the custom element. */ - private readonly tagName: string; + protected readonly tagName: string; /** * The inner HTML of the custom element. */ - private readonly innerHTML: string; + protected readonly innerHTML: string; - constructor(public readonly page: Page, tagName: string, innerHTML: string) { + /** + * Additional custom elements to wait for before running the test. + */ + protected readonly waitFor: string[]; + + /** + * Creates an instance of the CSRFixture. + * + * @param page - The Playwright page object. + * @param tagName - The tag name of the custom element. + * @param innerHTML - The inner HTML of the custom element. + * @param waitFor - Additional custom elements to wait for before running the test. + */ + constructor(public readonly page: Page, tagName: string, innerHTML: string, waitFor: string[] = []) { this.tagName = tagName; this.innerHTML = innerHTML; this.element = this.page.locator(this.tagName); + this.waitFor = waitFor; + + this.page.emulateMedia({ reducedMotion: 'reduce' }); + } + + /** + * Adds a style tag to the page. + * + * @param options - The options for the style tag. + * @see {@link Page.addStyleTag} + */ + async addStyleTag(options: Parameters[0]): Promise { + await this.page.addStyleTag(options); } - async goto() { - await this.page.goto('/'); + /** + * Navigates to the specified URL. + * @param url - The URL to navigate to. Defaults to "/". + * @returns A promise that resolves when the navigation is complete. + */ + async goto(url: string = '/') { + await this.page.goto(url); } + /** + * Generates the default template for the fixture. + * + * @param tagName - The tag name of the custom element. + * @param attributes - The attributes to set on the custom element. + * @param innerHTML - The inner HTML of the custom element. + * @returns The generated HTML string. + */ private defaultTemplate( tagName: string = this.tagName, attributes: Record = {}, @@ -47,34 +99,110 @@ export class FASTFixture { return `<${tagName} ${attributesString}>${innerHTML}`; } - async setTemplate( - templateOrOptions?: - | string - | { - attributes?: Record; - innerHTML?: string; - }, - ): Promise { + /** + * Sets the template for the fixture's page. + * + * When `templateOrOptions` is an object, the method merges specific + * template options with values configured via the Playwright `test.use` + * configuration for the current test suite. This allows for flexible test + * setups where common attributes or inner HTML can be defined at the suite + * level and overridden or augmented by individual tests. + * + * If `templateOrOptions` is a string, it is treated as the complete HTML + * body for the fixture. + * + * If `templateOrOptions` is not provided, the method uses the default + * template based on the fixture's `tagName` and `innerHTML` properties. + * + * @param templateOrOptions - The template configuration. It can be: + * - A raw HTML string (which will be treated as the complete `html` body). + * - An options object containing `innerHTML`, `attributes`, or other properties to construct the fixture. + * @returns A promise that resolves when the page has set the template and achieved stability. + */ + async setTemplate(templateOrOptions?: TemplateOrOptions): Promise { const template = typeof templateOrOptions === 'string' ? templateOrOptions : this.defaultTemplate(this.tagName, templateOrOptions?.attributes, templateOrOptions?.innerHTML); - const body = this.page.locator('body'); - - await body.evaluateHandle((node, template) => { + await this.page.locator('body').evaluate((node, template) => { const fragment = document.createRange().createContextualFragment(template); node.innerHTML = ''; node.append(fragment); }, template); - const bodyHandle = await body.elementHandle(); - if (bodyHandle) { - await bodyHandle.waitForElementState('stable'); + if (this.tagName) { + await this.waitForStability(); + } + } + + /** + * Updates the content of the fixture by modifying the specified element's + * attributes and/or inner HTML. + * + * @param locator - The locator or selector for the element to update. + * @param options - The options for updating the element, including attributes and/or inner HTML. + * @returns A promise that resolves when the element has been updated. + */ + async updateTemplate(locator: string | Locator, options: FixtureOptions): Promise { + const element = typeof locator === 'string' ? this.page.locator(locator) : locator; + + await element.evaluateHandle((node, options) => { + if (options.innerHTML) { + node.innerHTML = options.innerHTML; + } + + if (options.attributes) { + const attributesAsJSON = options.attributes; + + Object.entries(attributesAsJSON).forEach(([key, value]: [string, string | boolean]) => { + if (value === true) { + node.setAttribute(key, ''); + } else if (value === false) { + node.removeAttribute(key); + } else if (typeof value === 'string') { + node.setAttribute(key, value); + } + }); + } + }, options); + } + + /** + * Waits for the fixture to reach a stable state. + * + * This includes waiting for the custom element and any additional + * specified elements to be defined and for the body to become stable. + * @returns A promise that resolves when the fixture is stable. + */ + protected async waitForStability(): Promise { + if ((await this.element.count()) > 0) { + const elements = await this.page.locator([this.tagName, ...this.waitFor].join(',')).all(); + + await Promise.allSettled( + elements.map(element => + element.waitFor({ + state: 'attached', + timeout: 1000, + }), + ), + ); } + + await this.waitForCustomElement(this.tagName, ...this.waitFor); + + await (await this.page.locator('body').elementHandle())?.waitForElementState('stable'); } - async waitForCustomElement(tagName: string = this.tagName, ...tagNames: string[]) { + /** + * Waits for the specified custom elements to be defined in the browser's + * CustomElementRegistry. + * + * @param tagName - The primary tag name to wait for. + * @param tagNames - Additional tag names to wait for. + * @returns A promise that resolves when all specified custom elements are defined. + */ + async waitForCustomElement(tagName: string = this.tagName, ...tagNames: string[]): Promise { if (!tagName && !tagNames.length) { return; } From 9cea1f68f5a70758cba48a9fc140220891d8f53a Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:36:06 -0700 Subject: [PATCH 30/45] update docs --- .../web-components/docs/web-components.api.md | 77 ++++++++++++++----- .../src/rating-display/rating-display.base.ts | 11 ++- packages/web-components/src/slider/slider.ts | 6 ++ 3 files changed, 72 insertions(+), 22 deletions(-) diff --git a/packages/web-components/docs/web-components.api.md b/packages/web-components/docs/web-components.api.md index d37826b8bdf7c..d80cd26ab28e6 100644 --- a/packages/web-components/docs/web-components.api.md +++ b/packages/web-components/docs/web-components.api.md @@ -465,18 +465,32 @@ export class BaseAnchor extends FASTElement { // @public export class BaseAvatar extends FASTElement { constructor(); - // (undocumented) - connectedCallback(): void; + // @internal + protected cleanupSlottedContent(): void; // @internal defaultSlot: HTMLSlotElement; - // (undocumented) - disconnectedCallback(): void; + // @internal + defaultSlotChanged(): void; // @internal elementInternals: ElementInternals; + // @internal + generateInitials(): string | void; initials?: string | undefined; + // @internal + protected initialsChanged(): void; + // @internal + monogram: HTMLElement; + // @internal + protected monogramChanged(): void; name?: string | undefined; // @internal - slotchangeHandler(): void; + protected nameChanged(): void; + // @internal + slottedDefaults: Node[]; + // @internal + protected slottedDefaultsChanged(): void; + // @internal + protected updateMonogram(): void; } // @public @@ -489,8 +503,8 @@ export class BaseButton extends FASTElement { connectedCallback(): void; defaultSlottedContent: HTMLElement[]; disabled: boolean; - // (undocumented) - protected disabledChanged(): void; + // @internal + disabledChanged(): void; disabledFocusable: boolean; // @internal disabledFocusableChanged(previous: boolean, next: boolean): void; @@ -511,6 +525,8 @@ export class BaseButton extends FASTElement { name?: string; protected press(): void; resetForm(): void; + // @internal + protected setTabIndex(): void; type: ButtonType; // @internal typeChanged(previous: ButtonType, next: ButtonType): void; @@ -599,6 +615,8 @@ export class BaseDropdown extends FASTElement { changeHandler(e: Event): boolean | void; checkValidity(): boolean; clickHandler(e: PointerEvent): boolean | void; + // (undocumented) + connectedCallback(): void; // @internal control: HTMLInputElement; // @internal @@ -714,8 +732,6 @@ export class BaseField extends FASTElement { // @public export class BaseProgressBar extends FASTElement { constructor(); - // (undocumented) - connectedCallback(): void; // @internal elementInternals: ElementInternals; // @internal (undocumented) @@ -727,6 +743,8 @@ export class BaseProgressBar extends FASTElement { // @internal min?: number; protected minChanged(prev: number | undefined, next: number | undefined): void; + // @internal + protected setIndicatorWidth(): void; validationState: ProgressBarValidationState | null; validationStateChanged(prev: ProgressBarValidationState | undefined, next: ProgressBarValidationState | undefined): void; // @internal @@ -751,8 +769,10 @@ export class BaseRatingDisplay extends FASTElement { get formattedCount(): string; // @internal (undocumented) handleSlotChange(): void; - // @internal (undocumented) + // @internal iconSlot: HTMLSlotElement; + // @internal + iconSlotChanged(): void; // @deprecated iconViewBox?: string; max?: number; @@ -778,6 +798,7 @@ export class BaseSpinner extends FASTElement { // @public export class BaseTablist extends FASTElement { + constructor(); activeid: string; // @internal (undocumented) protected activeidChanged(oldValue: string, newValue: string): void; @@ -801,7 +822,7 @@ export class BaseTablist extends FASTElement { // @internal (undocumented) tabs: Tab[]; // @internal (undocumented) - protected tabsChanged(): void; + protected tabsChanged(prev: Tab[] | undefined, next: Tab[] | undefined): void; } // @public @@ -974,10 +995,14 @@ export class BaseTree extends FASTElement { childTreeItemsChanged(): void; // @internal clickHandler(e: Event): boolean | void; + // (undocumented) + connectedCallback(): void; currentSelected: HTMLElement | null; // @internal (undocumented) defaultSlot: HTMLSlotElement; // @internal + defaultSlotChanged(): void; + // @internal elementInternals: ElementInternals; // @internal focusHandler(e: FocusEvent): void; @@ -1087,7 +1112,7 @@ export const ButtonSize: { export type ButtonSize = ValuesOf; // @public -export const ButtonTemplate: ElementViewTemplate