diff --git a/change/@fluentui-web-components-3cb2badf-e1ee-4b92-a374-e8d45fb9c2d9.json b/change/@fluentui-web-components-3cb2badf-e1ee-4b92-a374-e8d45fb9c2d9.json new file mode 100644 index 00000000000000..d6e49f5391dc59 --- /dev/null +++ b/change/@fluentui-web-components-3cb2badf-e1ee-4b92-a374-e8d45fb9c2d9.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "[chore]: move core functionality to base dropdown", + "packageName": "@fluentui/web-components", + "email": "jes@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/docs/web-components.api.md b/packages/web-components/docs/web-components.api.md index 3f2c87624a831d..ce92223991e92e 100644 --- a/packages/web-components/docs/web-components.api.md +++ b/packages/web-components/docs/web-components.api.md @@ -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 @@ -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; @@ -2484,16 +2488,9 @@ export type DrawerType = ValuesOf; // @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; diff --git a/packages/web-components/src/dropdown/dropdown.base.ts b/packages/web-components/src/dropdown/dropdown.base.ts index 62689bbf3f9540..7054589ba24625 100644 --- a/packages/web-components/src/dropdown/dropdown.base.ts +++ b/packages/web-components/src/dropdown/dropdown.base.ts @@ -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'; @@ -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. * @@ -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); } /** @@ -594,6 +610,8 @@ export class BaseDropdown extends FASTElement { this.elementInternals.role = 'presentation'; + this.addEventListener('connected', this.listboxConnectedHandler); + Updates.enqueue(() => { this.insertControl(); }); @@ -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 }, + ); + } } diff --git a/packages/web-components/src/dropdown/dropdown.ts b/packages/web-components/src/dropdown/dropdown.ts index 901c54d5c77a2b..7af894889fcbb4 100644 --- a/packages/web-components/src/dropdown/dropdown.ts +++ b/packages/web-components/src/dropdown/dropdown.ts @@ -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'; @@ -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. * @@ -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 }, - ); - } } diff --git a/packages/web-components/src/listbox/listbox.stories.ts b/packages/web-components/src/listbox/listbox.stories.ts deleted file mode 100644 index 28d0c0517f6397..00000000000000 --- a/packages/web-components/src/listbox/listbox.stories.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { html, repeat } from '@microsoft/fast-element'; -import { type Meta, renderComponent, type StoryArgs, type StoryObj } from '../helpers.stories.js'; -import type { DropdownOption as FluentOption } from '../option/option.js'; -import type { Listbox as FluentListbox } from './listbox.js'; - -type Story = StoryObj; - -const optionTemplate = html>` - ${x => x.storyContent} -`; - -const storyTemplate = html>` - ${story => story.slottedContent?.()} -`; - -export default { - title: 'Components/Dropdown/Listbox', - render: renderComponent(storyTemplate), - argTypes: { - slottedContent: { table: { disable: true } }, - }, -} as Meta; - -export const Default: Story = { - args: { - slottedContent: () => - html`${repeat( - [ - { value: 'apple', storyContent: 'Apple' }, - { value: 'banana', storyContent: 'Banana' }, - { value: 'orange', storyContent: 'Orange' }, - { value: 'mango', storyContent: 'Mango' }, - { value: 'kiwi', storyContent: 'Kiwi' }, - { value: 'cherry', storyContent: 'Cherry' }, - { value: 'grapefruit', storyContent: 'Grapefruit' }, - { value: 'papaya', storyContent: 'Papaya' }, - { value: 'lychee', storyContent: 'Lychee' }, - ], - optionTemplate, - )}`, - }, -}; - -export const MultipleSelection: Story = { - args: { ...Default.args }, - decorators: [ - Story => { - const story = Story() as FluentListbox; - setTimeout(() => { - story.multiple = true; - }, 0); - - return story; - }, - ], -};