diff --git a/.changeset/short-crabs-retire.md b/.changeset/short-crabs-retire.md new file mode 100644 index 000000000..ada794306 --- /dev/null +++ b/.changeset/short-crabs-retire.md @@ -0,0 +1,5 @@ +--- +"@ebay/ebayui-core": major +--- + +feat(dropdowns): added support for floating-ui diff --git a/src/common/dropdown/index.ts b/src/common/dropdown/index.ts new file mode 100644 index 000000000..06450bf57 --- /dev/null +++ b/src/common/dropdown/index.ts @@ -0,0 +1,54 @@ +import { + autoUpdate, + flip, + computePosition, + shift, + offset, + type ReferenceElement +} from "@floating-ui/dom"; + +interface DropdownUtilOptions { + reverse?: boolean; + offset?: number +} + +export class DropdownUtil { + declare host: ReferenceElement; + declare overlay: HTMLElement; + declare cleanupFn: any; + declare options: DropdownUtilOptions; + + constructor(host: HTMLElement, overlay: HTMLElement, options?: DropdownUtilOptions) { + this.host = host as ReferenceElement; + this.overlay = overlay as HTMLElement; + this.options = options ?? {}; + } + + show() { + this.cleanupFn = autoUpdate( + this.host, + this.overlay, + this.update.bind(this), + ); + } + + update() { + computePosition(this.host, this.overlay, { + placement: this.options.reverse ? "bottom-end" : "bottom-start", + middleware: [offset(this.options.offset ?? 4), flip(), shift()], + }).then(({ x, y }) => { + Object.assign(this.overlay.style, { + left: `${x}px`, + top: `${y}px`, + }); + }); + } + + cleanup() { + this.cleanupFn?.(); + } + + hide() { + if (this.cleanup) this.cleanup(); + } +} diff --git a/src/components/ebay-chips-combobox/index.marko b/src/components/ebay-chips-combobox/index.marko index 511c6f3ea..7633d37de 100644 --- a/src/components/ebay-chips-combobox/index.marko +++ b/src/components/ebay-chips-combobox/index.marko @@ -13,6 +13,7 @@ $ const { $ const options = [...option || []].map((o) => o.text) o.text) onExpand("emit", "expand") onCollapse("emit", "collapse") disabled=disabled + dropdown-element=() => component.getEl("root") chevron-size="large" ...comboboxInput autocomplete="list" diff --git a/src/components/ebay-chips-combobox/test/__snapshots__/test.server.js.snap b/src/components/ebay-chips-combobox/test/__snapshots__/test.server.js.snap index 15935118a..f06ea4339 100644 --- a/src/components/ebay-chips-combobox/test/__snapshots__/test.server.js.snap +++ b/src/components/ebay-chips-combobox/test/__snapshots__/test.server.js.snap @@ -16,7 +16,7 @@ exports[`ebay-chips-combobox > renders default 1`] = ` aria-expanded="false" aria-haspopup="listbox" autocomplete="off" - id="s0-0-4-input" + id="s0-0-3-input" placeholder="Add item" role="combobox" type="text" @@ -92,12 +92,12 @@ exports[`ebay-chips-combobox > renders with chips already selected 1`] = ` >  Option 1   renders with chips already selected 1`] = ` >  Option 3   renders with chips already selected 1`] = ` >  Custom Option   renders with chips already selected 1`] = ` aria-expanded="false" aria-haspopup="listbox" autocomplete="off" - id="s0-0-4-input" + id="s0-0-3-input" placeholder="Add item" role="combobox" type="text" diff --git a/src/components/ebay-combobox/component.ts b/src/components/ebay-combobox/component.ts index 95d83635c..08b3c9308 100644 --- a/src/components/ebay-combobox/component.ts +++ b/src/components/ebay-combobox/component.ts @@ -1,6 +1,7 @@ import { createLinear } from "makeup-active-descendant"; import FloatingLabel from "makeup-floating-label"; import Expander from "makeup-expander"; +import { DropdownUtil } from "../../common/dropdown"; import { scroll } from "../../common/element-scroll"; import * as eventUtils from "../../common/event-utils"; import safeRegex from "../../common/build-safe-regex"; @@ -34,6 +35,11 @@ interface ComboboxInput extends Omit { }>; options?: Marko.AttrTag; "chevron-size"?: "large"; + /** + * For internal use only. Used when combobox container changes. + * @returns The dropdown element to be used for the combobox + */ + "dropdown-element"?: () => HTMLElement; "on-focus"?: (event: ComboboxEvent) => void; "on-button-click"?: (event: { originalEvent: MouseEvent }) => void; "on-expand"?: () => void; @@ -63,6 +69,7 @@ export default class Combobox extends Marko.Component { declare expanded?: boolean; declare expandedChange: boolean; declare _floatingLabel: any; + declare dropdownUtil: DropdownUtil; focus() { (this.getEl("combobox") as HTMLElement).focus(); @@ -119,11 +126,13 @@ export default class Combobox extends Marko.Component { handleExpand() { this.setSelectedView(); + this.dropdownUtil.show(); this.emit("expand"); } handleCollapse() { this.activeDescendant.reset(); + this.dropdownUtil.hide(); this.emit("collapse"); } @@ -230,6 +239,7 @@ export default class Combobox extends Marko.Component { this.expandedChange = input.expanded !== this.expanded; if (this.expandedChange) { this.expander.expanded = input.expanded; + } } this.expanded = input.expanded; @@ -307,9 +317,15 @@ export default class Combobox extends Marko.Component { } } + this.dropdownUtil = new DropdownUtil(this.input.dropdownElement?.() ?? this.getEl("combobox"), this.getEl("listbox")) + if (this.isExpanded()) { + this.dropdownUtil.show(); + } + if (this.input.floatingLabel) { this._setupFloatingLabel(); } + } _cleanupMakeup() { diff --git a/src/components/ebay-combobox/index.marko b/src/components/ebay-combobox/index.marko index 70856de65..5918ebf19 100644 --- a/src/components/ebay-combobox/index.marko +++ b/src/components/ebay-combobox/index.marko @@ -9,6 +9,7 @@ $ const { borderless, autocomplete, options, + dropdownElement, floatingLabel, listSelection, expanded, diff --git a/src/components/ebay-date-textbox/component.ts b/src/components/ebay-date-textbox/component.ts index e5c1ac002..9ecd8a03c 100644 --- a/src/components/ebay-date-textbox/component.ts +++ b/src/components/ebay-date-textbox/component.ts @@ -1,4 +1,5 @@ import Expander from "makeup-expander"; +import { DropdownUtil } from "../../common/dropdown"; import { type DayISO, dateArgToISO } from "../../common/dates/date-utils"; import type { WithNormalizedProps } from "../../global"; import type { AttrString } from "marko/tags-html"; @@ -56,6 +57,7 @@ interface State { class DateTextbox extends Marko.Component { declare expander: any; + declare dropdownUtil: DropdownUtil; onCreate() { this.state = { @@ -73,10 +75,13 @@ class DateTextbox extends Marko.Component { expandOnClick: true, autoCollapse: true, }); + + this.dropdownUtil = new DropdownUtil(this.el as HTMLElement, this.getEl("popover")) } onDestroy() { this.expander?.destroy(); + this.dropdownUtil?.cleanup(); } onInput(input: Input) { @@ -117,10 +122,12 @@ class DateTextbox extends Marko.Component { openPopover() { this.calculateNumMonths(); this.state.popover = true; + this.dropdownUtil.show(); } closePopover() { this.state.popover = false; + this.dropdownUtil.hide(); } onPopoverSelect({ iso }: { iso: DayISO }) { diff --git a/src/components/ebay-fake-menu-button/component.ts b/src/components/ebay-fake-menu-button/component.ts index 3542cf945..0c651f5f9 100644 --- a/src/components/ebay-fake-menu-button/component.ts +++ b/src/components/ebay-fake-menu-button/component.ts @@ -1,5 +1,6 @@ import Expander from "makeup-expander"; import * as eventUtils from "../../common/event-utils"; +import { DropdownUtil } from "../../common/dropdown"; import type { MenuEvent } from "../ebay-fake-menu/component"; import type { Input as FakeMenuInput, @@ -38,6 +39,7 @@ export interface Input extends WithNormalizedProps {} class FakeMenuButton extends Marko.Component { declare expander: any; + declare dropdownUtil: DropdownUtil; handleMenuKeydown({ el, originalEvent, index }: MenuEvent) { eventUtils.handleActionKeydown(originalEvent as KeyboardEvent, () => { @@ -59,10 +61,12 @@ class FakeMenuButton extends Marko.Component { } handleExpand() { + this.dropdownUtil.show(); this.emitComponentEvent({ eventType: "expand" }); } handleCollapse() { + this.dropdownUtil.hide(); this.emitComponentEvent({ eventType: "collapse" }); } @@ -129,12 +133,21 @@ class FakeMenuButton extends Marko.Component { autoCollapse: true, alwaysDoFocusManagement: true, }); + + this.dropdownUtil = new DropdownUtil( + this.getEl("button"), + this.getEl("content"), + { + reverse: this.input.reverse, + }, + ); } _cleanupMakeup() { if (this.expander) { this.expander.destroy(); } + this.dropdownUtil?.cleanup(); } } diff --git a/src/components/ebay-filter-menu-button/component.ts b/src/components/ebay-filter-menu-button/component.ts index 0888560f6..c49630ccc 100644 --- a/src/components/ebay-filter-menu-button/component.ts +++ b/src/components/ebay-filter-menu-button/component.ts @@ -1,4 +1,5 @@ import Expander from "makeup-expander"; +import { DropdownUtil } from "../../common/dropdown"; import * as eventUtils from "../../common/event-utils"; import setupMenu, { MenuUtils, @@ -49,6 +50,7 @@ export interface Input extends WithNormalizedProps {} export default class extends MenuUtils { declare _expander: any; + declare dropdownUtil: DropdownUtil; onCreate() { setupMenu(this); @@ -88,10 +90,12 @@ export default class extends MenuUtils { } handleExpand({ originalEvent }: FilterMenuEvent) { + this.dropdownUtil.show(); this._emitComponentEvent("expand", originalEvent); } handleCollapse({ originalEvent }: FilterMenuEvent) { + this.dropdownUtil.hide(); (this.getEl("button") as HTMLElement).focus(); this._emitComponentEvent("collapse", originalEvent); } @@ -157,6 +161,14 @@ export default class extends MenuUtils { autoCollapse: true, alwaysDoFocusManagement: true, }); + this.dropdownUtil = new DropdownUtil( + this.getEl("button"), + this.getEl("menu"), + { + offset: 8 + } + ); + } _cleanupMakeup() { @@ -164,5 +176,6 @@ export default class extends MenuUtils { this._expander.destroy(); this._expander = undefined; } + this.dropdownUtil?.cleanup(); } } diff --git a/src/components/ebay-filter-menu-button/index.marko b/src/components/ebay-filter-menu-button/index.marko index 7527a1551..1fa6754c8 100644 --- a/src/components/ebay-filter-menu-button/index.marko +++ b/src/components/ebay-filter-menu-button/index.marko @@ -41,6 +41,7 @@ $ const { passes through additional html attributes 1`] = `