diff --git a/change/@ni-nimble-components-f4992b1c-b7f0-46e1-a684-b5afff8683f6.json b/change/@ni-nimble-components-f4992b1c-b7f0-46e1-a684-b5afff8683f6.json new file mode 100644 index 0000000000..df0818759e --- /dev/null +++ b/change/@ni-nimble-components-f4992b1c-b7f0-46e1-a684-b5afff8683f6.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Align positioning behavior of rich-text mention listbox, combobox, select, and menu button", + "packageName": "@ni/nimble-components", + "email": "7282195+m-akinc@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/nimble-components/src/anchored-region/index.ts b/packages/nimble-components/src/anchored-region/index.ts index 1df8c8d2c8..ee3488a261 100644 --- a/packages/nimble-components/src/anchored-region/index.ts +++ b/packages/nimble-components/src/anchored-region/index.ts @@ -1,7 +1,9 @@ +import { observable } from '@ni/fast-element'; import { DesignSystem, AnchoredRegion as FoundationAnchoredRegion, - anchoredRegionTemplate as template + anchoredRegionTemplate as template, + type AnchoredRegionPositionLabel } from '@ni/fast-foundation'; import { styles } from './styles'; @@ -22,7 +24,11 @@ declare global { /** * A nimble-styled anchored region control. */ -export class AnchoredRegion extends FoundationAnchoredRegion {} +export class AnchoredRegion extends FoundationAnchoredRegion { + /* @internal */ + @observable + public override verticalPosition: AnchoredRegionPositionLabel | undefined; +} const nimbleAnchoredRegion = AnchoredRegion.compose({ baseName: 'anchored-region', diff --git a/packages/nimble-components/src/combobox/index.ts b/packages/nimble-components/src/combobox/index.ts index e8f036e52b..ae83c660eb 100644 --- a/packages/nimble-components/src/combobox/index.ts +++ b/packages/nimble-components/src/combobox/index.ts @@ -1,9 +1,16 @@ -import { DOM, Observable, attr, html, observable, ref } from '@ni/fast-element'; +import { + attr, + DOM, + html, + observable, + Observable, + ref, + type Notifier +} from '@ni/fast-element'; import { DesignSystem, type ComboboxOptions, ComboboxAutocomplete, - SelectPosition, ListboxOption, DelegatesARIACombobox, applyMixins, @@ -28,8 +35,10 @@ import { styles } from './styles'; import { mixinErrorPattern } from '../patterns/error/types'; import { DropdownAppearance, + DropdownPosition, type DropdownPattern } from '../patterns/dropdown/types'; +import { anchoredRegionPositionToDropdownPosition } from '../patterns/dropdown/utility'; import type { AnchoredRegion } from '../anchored-region'; import { template } from './template'; import { FormAssociatedCombobox } from './models/combobox-form-associated'; @@ -69,7 +78,7 @@ export class Combobox * The placement for the listbox when the combobox is open. */ @attr({ attribute: 'position' }) - public positionAttribute?: SelectPosition; + public positionAttribute?: DropdownPosition; /** * The open attribute. @@ -92,7 +101,7 @@ export class Combobox * @public */ @observable - public position?: SelectPosition; + public position?: DropdownPosition; /** * @internal @@ -206,11 +215,7 @@ export class Combobox private valueBeforeTextUpdate?: string; private _value = ''; private filter = ''; - - /** - * The initial state of the position attribute. - */ - private forcedPosition = false; + private anchoredRegionNotifier?: Notifier; private get isAutocompleteInline(): boolean { return ( @@ -245,11 +250,10 @@ export class Combobox public override connectedCallback(): void { super.connectedCallback(); - this.forcedPosition = !!this.positionAttribute; if (this.value) { this.initialValue = this.value; } - this.setPositioning(); + this.updateAvailableViewportHeight(); this.updateInputAriaLabel(); } @@ -620,35 +624,14 @@ export class Combobox } } - /** - * @internal - */ - public setPositioning(): void { - // Workaround for https://github.com/microsoft/fast/issues/5123 - if (!this.$fastController.isConnected) { - // Don't call setPositioning() until we're connected, - // since this.forcedPosition isn't initialized yet. - return; - } - const currentBox = this.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - const availableBottom = viewportHeight - currentBox.bottom; - - if (this.forcedPosition) { - this.position = this.positionAttribute; - } else if (currentBox.top > availableBottom) { - this.position = SelectPosition.above; - } else { - this.position = SelectPosition.below; + /* @internal */ + public override handleChange(source: unknown, propertyName: string): void { + super.handleChange(source, propertyName); + if (propertyName === 'verticalPosition') { + this.position = anchoredRegionPositionToDropdownPosition( + this.region?.verticalPosition + ); } - - this.positionAttribute = this.forcedPosition - ? this.positionAttribute - : this.position; - - this.availableViewportHeight = this.position === SelectPosition.above - ? Math.trunc(currentBox.top) - : Math.trunc(availableBottom); } /** @@ -678,7 +661,7 @@ export class Combobox this.ariaControls = this.listboxId; this.ariaExpanded = 'true'; - this.setPositioning(); + this.updateAvailableViewportHeight(); this.focusAndScrollOptionIntoView(); // focus is directed to the element when `open` is changed programmatically @@ -729,19 +712,24 @@ export class Combobox } protected positionChanged( - _: SelectPosition | undefined, - next: SelectPosition | undefined + _prev: DropdownPosition | undefined, + _next: DropdownPosition | undefined ): void { - this.positionAttribute = next; - this.setPositioning(); + this.updateAvailableViewportHeight(); } private regionChanged( _prev: AnchoredRegion | undefined, _next: AnchoredRegion | undefined ): void { + if (this.anchoredRegionNotifier) { + this.anchoredRegionNotifier?.unsubscribe(this, 'verticalPosition'); + this.anchoredRegionNotifier = undefined; + } if (this.region && this.controlWrapper) { this.region.anchorElement = this.controlWrapper; + this.anchoredRegionNotifier = Observable.getNotifier(this.region); + this.anchoredRegionNotifier.subscribe(this, 'verticalPosition'); } } @@ -759,6 +747,29 @@ export class Combobox this.updateInputAriaLabel(); } + private updateAvailableViewportHeight(): void { + const currentBox = this.getBoundingClientRect(); + const viewportHeight = document.documentElement.getBoundingClientRect().height; + const availableSpaceAbove = Math.trunc(currentBox.top); + const availableSpaceBelow = Math.trunc( + viewportHeight - currentBox.bottom + ); + + switch (this.positionAttribute) { + case DropdownPosition.above: + this.availableViewportHeight = availableSpaceAbove; + break; + case DropdownPosition.below: + this.availableViewportHeight = availableSpaceBelow; + break; + default: + this.availableViewportHeight = Math.max( + availableSpaceAbove, + availableSpaceBelow + ); + } + } + /** * Sets the value and to match the first selected option. */ diff --git a/packages/nimble-components/src/combobox/template.ts b/packages/nimble-components/src/combobox/template.ts index c4eacb369c..a4f37a7eaf 100644 --- a/packages/nimble-components/src/combobox/template.ts +++ b/packages/nimble-components/src/combobox/template.ts @@ -28,7 +28,7 @@ ComboboxOptions