Skip to content

[chore] move core functionality of dropdown to base class #34033

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "[chore]: move core functionality to base dropdown",
"packageName": "@fluentui/web-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
11 changes: 4 additions & 7 deletions packages/web-components/docs/web-components.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,8 @@ export class BaseDropdown extends FASTElement {
ariaLabelledBy: string;
changeHandler(e: Event): boolean | void;
clickHandler(e: PointerEvent): boolean | void;
// (undocumented)
connectedCallback(): void;
// @internal
control: HTMLInputElement;
// @internal
Expand All @@ -608,6 +610,8 @@ export class BaseDropdown extends FASTElement {
controlSlot: HTMLSlotElement;
disabled?: boolean;
disabledChanged(prev: boolean | undefined, next: boolean | undefined): void;
// (undocumented)
disconnectedCallback(): void;
get displayValue(): string;
// @internal
elementInternals: ElementInternals;
Expand Down Expand Up @@ -2484,16 +2488,9 @@ export type DrawerType = ValuesOf<typeof DrawerType>;

// @public
export class Dropdown extends BaseDropdown {
constructor();
appearance: DropdownAppearance;
// @internal
appearanceChanged(prev: DropdownAppearance | undefined, next: DropdownAppearance | undefined): void;
// (undocumented)
connectedCallback(): void;
// (undocumented)
disconnectedCallback(): void;
// @internal
openChanged(prev: boolean | undefined, next: boolean | undefined): void;
size?: DropdownSize;
// @internal
sizeChanged(prev: DropdownSize | undefined, next: DropdownSize | undefined): void;
Expand Down
71 changes: 71 additions & 0 deletions packages/web-components/src/dropdown/dropdown.base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { attr, FASTElement, Observable, observable, Updates, volatile } from '@microsoft/fast-element';
import type { Listbox } from '../listbox/listbox.js';
import { isListbox } from '../listbox/listbox.options.js';
import type { DropdownOption } from '../option/option.js';
import { isDropdownOption } from '../option/option.options.js';
import { toggleState } from '../utils/element-internals.js';
Expand All @@ -24,6 +25,14 @@ import { dropdownButtonTemplate, dropdownInputTemplate } from './dropdown.templa
* @public
*/
export class BaseDropdown extends FASTElement {
/**
* Static property for the anchor positioning fallback observer. The observer is used to flip the listbox when it is
* out of view.
* @remarks This is only used when the browser does not support CSS anchor positioning.
* @internal
*/
private static AnchorPositionFallbackObserver: IntersectionObserver;

/**
* The ID of the current active descendant.
*
Expand Down Expand Up @@ -307,6 +316,13 @@ export class BaseDropdown extends FASTElement {
toggleState(this.elementInternals, 'open', next);
this.elementInternals.ariaExpanded = next ? 'true' : 'false';
this.activeIndex = this.selectedIndex ?? -1;

if (next) {
BaseDropdown.AnchorPositionFallbackObserver?.observe(this.listbox);
return;
}

BaseDropdown.AnchorPositionFallbackObserver?.unobserve(this.listbox);
}

/**
Expand Down Expand Up @@ -594,6 +610,8 @@ export class BaseDropdown extends FASTElement {

this.elementInternals.role = 'presentation';

this.addEventListener('connected', this.listboxConnectedHandler);

Updates.enqueue(() => {
this.insertControl();
});
Expand Down Expand Up @@ -866,4 +884,57 @@ export class BaseDropdown extends FASTElement {
this.freeformOption.value = value;
this.freeformOption.hidden = false;
}

connectedCallback(): void {
super.connectedCallback();
this.anchorPositionFallback();
}

disconnectedCallback(): void {
BaseDropdown.AnchorPositionFallbackObserver?.unobserve(this.listbox);

super.disconnectedCallback();
}

/**
* Handles the connected event for the listbox.
*
* @param e - the event object
* @internal
*/
private listboxConnectedHandler(e: Event): void {
const target = e.target as HTMLElement;

if (isListbox(target)) {
this.listbox = target;
}
}

/**
* When anchor positioning isn't supported, an intersection observer is used to flip the listbox when it hits the
* viewport bounds. One static observer is used for all dropdowns.
*
* @internal
*/
private anchorPositionFallback(): void {
BaseDropdown.AnchorPositionFallbackObserver =
BaseDropdown.AnchorPositionFallbackObserver ??
new IntersectionObserver(
(entries: IntersectionObserverEntry[]): void => {
entries.forEach(({ boundingClientRect, isIntersecting, target }) => {
if (isListbox(target) && !isIntersecting) {
if (boundingClientRect.bottom > window.innerHeight) {
toggleState(target.dropdown!.elementInternals, 'flip-block', true);
return;
}

if (boundingClientRect.top < 0) {
toggleState(target.dropdown!.elementInternals, 'flip-block', false);
}
}
});
},
{ threshold: 1 },
);
}
}
88 changes: 1 addition & 87 deletions packages/web-components/src/dropdown/dropdown.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { attr } from '@microsoft/fast-element';
import { isListbox } from '../listbox/listbox.options.js';
import { swapStates, toggleState } from '../utils/element-internals.js';
import { swapStates } from '../utils/element-internals.js';
import { BaseDropdown } from './dropdown.base.js';
import { DropdownAppearance, DropdownSize } from './dropdown.options.js';

Expand All @@ -14,14 +13,6 @@ import { DropdownAppearance, DropdownSize } from './dropdown.options.js';
* @public
*/
export class Dropdown extends BaseDropdown {
/**
* Static property for the anchor positioning fallback observer. The observer is used to flip the listbox when it is
* out of view.
* @remarks This is only used when the browser does not support CSS anchor positioning.
* @internal
*/
private static AnchorPositionFallbackObserver: IntersectionObserver;

/**
* The appearance of the dropdown.
*
Expand Down Expand Up @@ -62,81 +53,4 @@ export class Dropdown extends BaseDropdown {
public sizeChanged(prev: DropdownSize | undefined, next: DropdownSize | undefined): void {
swapStates(this.elementInternals, prev, next, DropdownSize);
}

connectedCallback(): void {
super.connectedCallback();
this.anchorPositionFallback();
}

constructor() {
super();

this.addEventListener('connected', this.listboxConnectedHandler);
}

disconnectedCallback(): void {
Dropdown.AnchorPositionFallbackObserver?.unobserve(this.listbox);

super.disconnectedCallback();
}

/**
* Handles the connected event for the listbox.
*
* @param e - the event object
* @internal
*/
private listboxConnectedHandler(e: Event): void {
const target = e.target as HTMLElement;

if (isListbox(target)) {
this.listbox = target;
}
}

/**
* Adds or removes the window event listener based on the open state.
*
* @param prev - the previous open state
* @param next - the current open state
* @internal
*/
public openChanged(prev: boolean | undefined, next: boolean | undefined): void {
super.openChanged(prev, next);

if (next) {
Dropdown.AnchorPositionFallbackObserver?.observe(this.listbox);
return;
}

Dropdown.AnchorPositionFallbackObserver?.unobserve(this.listbox);
}

/**
* When anchor positioning isn't supported, an intersection observer is used to flip the listbox when it hits the
* viewport bounds. One static observer is used for all dropdowns.
*
* @internal
*/
private anchorPositionFallback(): void {
Dropdown.AnchorPositionFallbackObserver =
Dropdown.AnchorPositionFallbackObserver ??
new IntersectionObserver(
(entries: IntersectionObserverEntry[]): void => {
entries.forEach(({ boundingClientRect, isIntersecting, target }) => {
if (isListbox(target) && !isIntersecting) {
if (boundingClientRect.bottom > window.innerHeight) {
toggleState(target.dropdown!.elementInternals, 'flip-block', true);
return;
}

if (boundingClientRect.top < 0) {
toggleState(target.dropdown!.elementInternals, 'flip-block', false);
}
}
});
},
{ threshold: 1 },
);
}
}
56 changes: 0 additions & 56 deletions packages/web-components/src/listbox/listbox.stories.ts

This file was deleted.