diff --git a/packages/components/src/components/block/block.tsx b/packages/components/src/components/block/block.tsx index 66fa95621d0..3e114fb2f2d 100644 --- a/packages/components/src/components/block/block.tsx +++ b/packages/components/src/components/block/block.tsx @@ -5,7 +5,7 @@ import { slotChangeGetAssignedElements, slotChangeHasAssignedElement } from "../ import { Heading, HeadingLevel } from "../functional/Heading"; import { FlipContext, Position, Scale, Status } from "../interfaces"; import { getIconScale } from "../../utils/component"; -import { toggleOpenClose } from "../../utils/openCloseComponent"; +import { useOpenClose } from "../../controllers/useOpenClose"; import { defaultEndMenuPlacement, FlipPlacement, @@ -67,6 +67,16 @@ export class Block extends LitElement { private interactiveContainer = useInteractive(this); + openCloseController = useOpenClose({ + lifecycle: { + onBeforeOpen: (host) => host.onBeforeOpen(), + onOpen: (host) => host.onOpen(), + onBeforeClose: (host) => host.onBeforeClose(), + onClose: (host) => host.onClose(), + }, + visibilityProps: ["expanded"], + })(this); + //#endregion //#region State Properties @@ -309,10 +319,6 @@ export class Block extends LitElement { To account for this semantics change, the checks for (this.hasUpdated || value != defaultValue) was added in this method Please refactor your code to reduce the need for this check. Docs: https://webgis.esri.com/arcgis-components/?path=/docs/lumina-transition-from-stencil--docs#watching-for-property-changes */ - if (changes.has("expanded") && (this.hasUpdated || this.expanded !== false)) { - toggleOpenClose(this); - } - if (changes.has("sortHandleOpen") && (this.hasUpdated || this.sortHandleOpen !== false)) { this.sortHandleOpenHandler(); } diff --git a/packages/components/src/components/combobox/combobox.tsx b/packages/components/src/components/combobox/combobox.tsx index b98c5ef69a0..9a6f5182d87 100644 --- a/packages/components/src/components/combobox/combobox.tsx +++ b/packages/components/src/components/combobox/combobox.tsx @@ -15,6 +15,7 @@ import { stringOrBoolean, } from "@arcgis/lumina"; import { useDirection } from "@arcgis/lumina/controllers"; +import { useOpenClose } from "../../controllers/useOpenClose"; import { filter } from "../../utils/filter"; import { focusElement, getElementWidth, getTextWidth } from "../../utils/dom"; import { @@ -42,7 +43,6 @@ import { import { guid } from "../../utils/guid"; import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label"; import { createObserver, updateRefObserver } from "../../utils/observers"; -import { toggleOpenClose } from "../../utils/openCloseComponent"; import { DEBOUNCE } from "../../utils/resources"; import { Scale, SelectionMode, Status } from "../interfaces"; import { CSS as XButtonCSS, XButton } from "../functional/XButton"; @@ -295,6 +295,17 @@ export class Combobox target: () => this.floatingEl, })(this); + openCloseController = useOpenClose({ + lifecycle: { + onBeforeOpen: (host) => host.onBeforeOpen(), + onOpen: (host) => host.onOpen(), + onBeforeClose: (host) => host.onBeforeClose(), + onClose: (host) => host.onClose(), + }, + visibilityProps: ["open"], + shouldToggle: (host) => !host.disabled, + })(this); + //#endregion //#region State Properties @@ -683,8 +694,6 @@ export class Combobox if (this.disabled) { return; } - - toggleOpenClose(this); } private handleDisabledChange(value: boolean): void { diff --git a/packages/components/src/components/dialog/dialog.tsx b/packages/components/src/components/dialog/dialog.tsx index 8c9086b0099..155236eb217 100644 --- a/packages/components/src/components/dialog/dialog.tsx +++ b/packages/components/src/components/dialog/dialog.tsx @@ -7,7 +7,8 @@ import { createEvent, h, JsxNode, LitElement, method, property, state } from "@a import { getStylePixelValue } from "../../utils/dom"; import { createObserver } from "../../utils/observers"; import { getDimensionClass } from "../../utils/dynamicClasses"; -import { OpenCloseComponentWithEl, toggleOpenClose } from "../../utils/openCloseComponent"; +import { OpenCloseComponentWithEl } from "../../utils/openCloseComponent"; +import { useOpenClose } from "../../controllers/useOpenClose"; import { Kind, Scale, Width } from "../interfaces"; import { SLOTS as PANEL_SLOTS } from "../panel/resources"; import { HeadingLevel } from "../functional/Heading"; @@ -86,8 +87,6 @@ export class Dialog extends LitElement implements OpenCloseComponentWithEl { private _open = false; - openProp = "opened"; - transitionProp = "opacity" as const; private panelRef = createRef(); @@ -124,6 +123,17 @@ export class Dialog extends LitElement implements OpenCloseComponentWithEl { target: this.popoverRef, })(this); + openCloseController = useOpenClose({ + lifecycle: { + onBeforeOpen: (host) => host.onBeforeOpen(), + onOpen: (host) => host.onOpen(), + onBeforeClose: (host) => host.onBeforeClose(), + onClose: (host) => host.onClose(), + }, + customVisibilityProps: ["opened"], + isOpen: (host) => host.opened, + })(this); + //#endregion //#region State Properties @@ -391,10 +401,6 @@ export class Dialog extends LitElement implements OpenCloseComponentWithEl { ) { this.updateAssistiveText(); } - - if (changes.has("opened") && (this.hasUpdated || this.opened !== false)) { - this.handleOpenedChange(); - } } override disconnectedCallback(): void { @@ -461,10 +467,6 @@ export class Dialog extends LitElement implements OpenCloseComponentWithEl { this.opened = value; } - private handleOpenedChange(): void { - toggleOpenClose(this); - } - private async triggerInteractModifiers(): Promise { const { interaction } = this; diff --git a/packages/components/src/components/input-date-picker/input-date-picker.tsx b/packages/components/src/components/input-date-picker/input-date-picker.tsx index 093c104ae3c..1b482dc4db5 100644 --- a/packages/components/src/components/input-date-picker/input-date-picker.tsx +++ b/packages/components/src/components/input-date-picker/input-date-picker.tsx @@ -13,6 +13,7 @@ import { } from "@arcgis/lumina"; import { useDirection } from "@arcgis/lumina/controllers"; import { useFocusTrap } from "../../controllers/useFocusTrap"; +import { useOpenClose } from "../../controllers/useOpenClose"; import { dateFromISO, dateFromLocalizedString, @@ -52,7 +53,6 @@ import { NumberingSystem, numberStringFormatter, } from "../../utils/locale"; -import { toggleOpenClose } from "../../utils/openCloseComponent"; import { DateLocaleData, getLocaleData, @@ -185,6 +185,17 @@ export class InputDatePicker target: () => this.floatingEl, })(this); + openCloseController = useOpenClose({ + lifecycle: { + onBeforeOpen: (host) => host.onBeforeOpen(), + onOpen: (host) => host.onOpen(), + onBeforeClose: (host) => host.onBeforeClose(), + onClose: (host) => host.onClose(), + }, + visibilityProps: ["open"], + shouldToggle: (host) => !host.disabled && !host.readOnly, + })(this); + //#endregion //#region State Properties @@ -606,7 +617,6 @@ export class InputDatePicker return; } - toggleOpenClose(this); this.reposition(true); } diff --git a/packages/components/src/controllers/useOpenClose.browser.spec.tsx b/packages/components/src/controllers/useOpenClose.browser.spec.tsx new file mode 100644 index 00000000000..24700370dc8 --- /dev/null +++ b/packages/components/src/controllers/useOpenClose.browser.spec.tsx @@ -0,0 +1,606 @@ +import { describe, it, expect, vi } from "vitest"; +import { h, JsxNode, LitElement, property } from "@arcgis/lumina"; +import { mount } from "@arcgis/lumina-compiler/testing"; +import { createRef } from "lit/directives/ref.js"; +import { afterNextFrame } from "../tests/utils/timing"; +import { createControlledPromise } from "../tests/utils/promises"; +import { useOpenClose } from "./useOpenClose"; + +describe("useOpenClose", () => { + it("emits beforeOpen/open and beforeClose/close for expanded", async () => { + const emitted: string[] = []; + + class Test extends LitElement { + @property() expanded = false; + + transitionProp = "opacity" as const; + transitionEl!: HTMLDivElement; + + openCloseController = useOpenClose({ + lifecycle: { + onBeforeOpen: () => this.onBeforeOpen(), + onOpen: () => this.onOpen(), + onBeforeClose: () => this.onBeforeClose(), + onClose: () => this.onClose(), + }, + visibilityProps: ["expanded"], + })(this); + + onBeforeOpen(): void { + emitted.push("beforeOpen"); + } + + onOpen(): void { + emitted.push("open"); + } + + onBeforeClose(): void { + emitted.push("beforeClose"); + } + + onClose(): void { + emitted.push("close"); + } + + override render(): JsxNode { + return ( +
{ + if (el) { + this.transitionEl = el; + } + }} + /> + ); + } + } + + const { component } = await mount(Test); + await component.updateComplete; + + const openingControlledPromise = createControlledPromise(); + const getAnimationsSpy = vi.spyOn(component.transitionEl, "getAnimations"); + getAnimationsSpy.mockImplementation(() => [ + { + transitionProperty: "opacity", + finished: openingControlledPromise.promise, + } as unknown as CSSTransition, + ]); + + component.expanded = true; + await component.updateComplete; + await afterNextFrame(); + expect(emitted).toEqual(["beforeOpen"]); + + openingControlledPromise.resolve(); + await afterNextFrame(); + expect(emitted).toEqual(["beforeOpen", "open"]); + + const closingControlledPromise = createControlledPromise(); + getAnimationsSpy.mockImplementation(() => [ + { + transitionProperty: "opacity", + finished: closingControlledPromise.promise, + } as unknown as CSSTransition, + ]); + + component.expanded = false; + await component.updateComplete; + await afterNextFrame(); + expect(emitted).toEqual(["beforeOpen", "open", "beforeClose"]); + + closingControlledPromise.resolve(); + await afterNextFrame(); + expect(emitted).toEqual(["beforeOpen", "open", "beforeClose", "close"]); + }); + + it("treats closed=true as closed and closed=false as open", async () => { + const emitted: string[] = []; + + class Test extends LitElement { + @property() closed = false; + + transitionProp = "opacity" as const; + transitionEl!: HTMLDivElement; + + openCloseController = useOpenClose({ + lifecycle: { + onBeforeOpen: () => this.onBeforeOpen(), + onOpen: () => this.onOpen(), + onBeforeClose: () => this.onBeforeClose(), + onClose: () => this.onClose(), + }, + visibilityProps: ["closed"], + })(this); + + onBeforeOpen(): void { + emitted.push("beforeOpen"); + } + + onOpen(): void { + emitted.push("open"); + } + + onBeforeClose(): void { + emitted.push("beforeClose"); + } + + onClose(): void { + emitted.push("close"); + } + + override render(): JsxNode { + return ( +
{ + if (el) { + this.transitionEl = el; + } + }} + /> + ); + } + } + + const { component } = await mount(Test); + await component.updateComplete; + + const openingControlledPromise = createControlledPromise(); + const getAnimationsSpy = vi.spyOn(component.transitionEl, "getAnimations"); + getAnimationsSpy.mockImplementation(() => [ + { + transitionProperty: "opacity", + finished: openingControlledPromise.promise, + } as unknown as CSSTransition, + ]); + + component.closed = true; + await component.updateComplete; + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose"]); + + openingControlledPromise.resolve(); + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose", "close"]); + + const closingControlledPromise = createControlledPromise(); + getAnimationsSpy.mockImplementation(() => [ + { + transitionProperty: "opacity", + finished: closingControlledPromise.promise, + } as unknown as CSSTransition, + ]); + + component.closed = false; + await component.updateComplete; + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose", "close", "beforeOpen"]); + + closingControlledPromise.resolve(); + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose", "close", "beforeOpen", "open"]); + }); + + it("emits lifecycle events for open", async () => { + const emitted: string[] = []; + + class Test extends LitElement { + @property() open = false; + + transitionProp = "opacity" as const; + transitionEl!: HTMLDivElement; + + openCloseController = useOpenClose({ + lifecycle: { + onBeforeOpen: () => this.onBeforeOpen(), + onOpen: () => this.onOpen(), + onBeforeClose: () => this.onBeforeClose(), + onClose: () => this.onClose(), + }, + visibilityProps: ["open"], + })(this); + + onBeforeOpen(): void { + emitted.push("beforeOpen"); + } + + onOpen(): void { + emitted.push("open"); + } + + onBeforeClose(): void { + emitted.push("beforeClose"); + } + + onClose(): void { + emitted.push("close"); + } + + override render(): JsxNode { + return ( +
{ + if (el) { + this.transitionEl = el; + } + }} + /> + ); + } + } + + const { component } = await mount(Test); + await component.updateComplete; + + const getAnimationsSpy = vi.spyOn(component.transitionEl, "getAnimations"); + + const opening = createControlledPromise(); + getAnimationsSpy.mockImplementation(() => [ + { transitionProperty: "opacity", finished: opening.promise } as unknown as CSSTransition, + ]); + + component.open = true; + await component.updateComplete; + await afterNextFrame(); + expect(emitted).toEqual(["beforeOpen"]); + + opening.resolve(); + await afterNextFrame(); + expect(emitted).toEqual(["beforeOpen", "open"]); + + const closing = createControlledPromise(); + getAnimationsSpy.mockImplementation(() => [ + { transitionProperty: "opacity", finished: closing.promise } as unknown as CSSTransition, + ]); + + component.open = false; + await component.updateComplete; + await afterNextFrame(); + expect(emitted).toEqual(["beforeOpen", "open", "beforeClose"]); + + closing.resolve(); + await afterNextFrame(); + expect(emitted).toEqual(["beforeOpen", "open", "beforeClose", "close"]); + }); + + it("treats collapsed=true as closed and collapsed=false as open", async () => { + const emitted: string[] = []; + + class Test extends LitElement { + @property() collapsed = false; + + transitionProp = "opacity" as const; + transitionEl!: HTMLDivElement; + + openCloseController = useOpenClose({ + lifecycle: { + onBeforeOpen: () => this.onBeforeOpen(), + onOpen: () => this.onOpen(), + onBeforeClose: () => this.onBeforeClose(), + onClose: () => this.onClose(), + }, + visibilityProps: ["collapsed"], + })(this); + + onBeforeOpen(): void { + emitted.push("beforeOpen"); + } + + onOpen(): void { + emitted.push("open"); + } + + onBeforeClose(): void { + emitted.push("beforeClose"); + } + + onClose(): void { + emitted.push("close"); + } + + override render(): JsxNode { + return ( +
{ + if (el) { + this.transitionEl = el; + } + }} + /> + ); + } + } + + const { component } = await mount(Test); + await component.updateComplete; + + const getAnimationsSpy = vi.spyOn(component.transitionEl, "getAnimations"); + + const closing = createControlledPromise(); + getAnimationsSpy.mockImplementation(() => [ + { transitionProperty: "opacity", finished: closing.promise } as unknown as CSSTransition, + ]); + + component.collapsed = true; + await component.updateComplete; + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose"]); + + closing.resolve(); + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose", "close"]); + + const opening = createControlledPromise(); + getAnimationsSpy.mockImplementation(() => [ + { transitionProperty: "opacity", finished: opening.promise } as unknown as CSSTransition, + ]); + + component.collapsed = false; + await component.updateComplete; + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose", "close", "beforeOpen"]); + + opening.resolve(); + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose", "close", "beforeOpen", "open"]); + }); + + it("uses transitionRef when provided", async () => { + const emitted: string[] = []; + + class Test extends LitElement { + @property() open = false; + + transitionProp = "opacity" as const; + transitionRef = createRef(); + + openCloseController = useOpenClose({ + lifecycle: { + onBeforeOpen: () => this.onBeforeOpen(), + onOpen: () => this.onOpen(), + onBeforeClose: () => this.onBeforeClose(), + onClose: () => this.onClose(), + }, + visibilityProps: ["open"], + })(this); + + onBeforeOpen(): void { + emitted.push("beforeOpen"); + } + + onOpen(): void { + emitted.push("open"); + } + + onBeforeClose(): void { + emitted.push("beforeClose"); + } + + onClose(): void { + emitted.push("close"); + } + + override render(): JsxNode { + return
; + } + } + + const { component } = await mount(Test); + await component.updateComplete; + + const opening = createControlledPromise(); + const getAnimationsSpy = vi.spyOn(component.transitionRef.value, "getAnimations"); + getAnimationsSpy.mockImplementation(() => [ + { transitionProperty: "opacity", finished: opening.promise } as unknown as CSSTransition, + ]); + + component.open = true; + await component.updateComplete; + await afterNextFrame(); + expect(emitted).toEqual(["beforeOpen"]); + + opening.resolve(); + await afterNextFrame(); + expect(emitted).toEqual(["beforeOpen", "open"]); + }); + + it("still emits events when no matching transition is found", async () => { + const emitted: string[] = []; + + class Test extends LitElement { + @property() open = false; + + transitionProp = "opacity" as const; + transitionEl!: HTMLDivElement; + + openCloseController = useOpenClose({ + lifecycle: { + onBeforeOpen: () => this.onBeforeOpen(), + onOpen: () => this.onOpen(), + onBeforeClose: () => this.onBeforeClose(), + onClose: () => this.onClose(), + }, + visibilityProps: ["open"], + })(this); + + onBeforeOpen(): void { + emitted.push("beforeOpen"); + } + + onOpen(): void { + emitted.push("open"); + } + + onBeforeClose(): void { + emitted.push("beforeClose"); + } + + onClose(): void { + emitted.push("close"); + } + + override render(): JsxNode { + return ( +
{ + if (el) { + this.transitionEl = el; + } + }} + /> + ); + } + } + + const { component } = await mount(Test); + await component.updateComplete; + + vi.spyOn(component.transitionEl, "getAnimations").mockReturnValue([]); + + component.open = true; + await component.updateComplete; + await afterNextFrame(); + await afterNextFrame(); // whenTransitionDone checks next frame before bailing out + expect(emitted).toEqual(["beforeOpen", "open"]); + }); + + it("supports multiple configured visibility props in one controller", async () => { + const emitted: string[] = []; + + class Test extends LitElement { + @property() collapsed = false; + @property() closed = false; + + transitionProp = "opacity" as const; + transitionEl!: HTMLDivElement; + + openCloseController = useOpenClose({ + lifecycle: { + onBeforeOpen: () => this.onBeforeOpen(), + onOpen: () => this.onOpen(), + onBeforeClose: () => this.onBeforeClose(), + onClose: () => this.onClose(), + }, + visibilityProps: ["collapsed", "closed"], + })(this); + + onBeforeOpen(): void { + emitted.push("beforeOpen"); + } + + onOpen(): void { + emitted.push("open"); + } + + onBeforeClose(): void { + emitted.push("beforeClose"); + } + + onClose(): void { + emitted.push("close"); + } + + override render(): JsxNode { + return ( +
{ + if (el) { + this.transitionEl = el; + } + }} + /> + ); + } + } + + const { component } = await mount(Test); + await component.updateComplete; + + const getAnimationsSpy = vi.spyOn(component.transitionEl, "getAnimations"); + + const closingControlledPromise = createControlledPromise(); + getAnimationsSpy.mockImplementation(() => [ + { + transitionProperty: "opacity", + finished: closingControlledPromise.promise, + } as unknown as CSSTransition, + ]); + + component.closed = true; + await component.updateComplete; + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose"]); + + closingControlledPromise.resolve(); + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose", "close"]); + + const openingControlledPromise = createControlledPromise(); + getAnimationsSpy.mockImplementation(() => [ + { + transitionProperty: "opacity", + finished: openingControlledPromise.promise, + } as unknown as CSSTransition, + ]); + + component.closed = false; + await component.updateComplete; + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose", "close", "beforeOpen"]); + + openingControlledPromise.resolve(); + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose", "close", "beforeOpen", "open"]); + + const closeFromCollapsedControlledPromise = createControlledPromise(); + getAnimationsSpy.mockImplementation(() => [ + { + transitionProperty: "opacity", + finished: closeFromCollapsedControlledPromise.promise, + } as unknown as CSSTransition, + ]); + + component.collapsed = true; + await component.updateComplete; + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose", "close", "beforeOpen", "open", "beforeClose"]); + + closeFromCollapsedControlledPromise.resolve(); + await afterNextFrame(); + expect(emitted).toEqual(["beforeClose", "close", "beforeOpen", "open", "beforeClose", "close"]); + + const openFromCollapsedControlledPromise = createControlledPromise(); + getAnimationsSpy.mockImplementation(() => [ + { + transitionProperty: "opacity", + finished: openFromCollapsedControlledPromise.promise, + } as unknown as CSSTransition, + ]); + + component.collapsed = false; + await component.updateComplete; + await afterNextFrame(); + expect(emitted).toEqual([ + "beforeClose", + "close", + "beforeOpen", + "open", + "beforeClose", + "close", + "beforeOpen", + ]); + + openFromCollapsedControlledPromise.resolve(); + await afterNextFrame(); + expect(emitted).toEqual([ + "beforeClose", + "close", + "beforeOpen", + "open", + "beforeClose", + "close", + "beforeOpen", + "open", + ]); + }); +}); diff --git a/packages/components/src/controllers/useOpenClose.ts b/packages/components/src/controllers/useOpenClose.ts new file mode 100644 index 00000000000..c26692d5bb2 --- /dev/null +++ b/packages/components/src/controllers/useOpenClose.ts @@ -0,0 +1,169 @@ +import { makeGenericController } from "@arcgis/lumina/controllers"; +import { LitElement } from "@arcgis/lumina"; +import { Ref } from "lit/directives/ref.js"; +import type { KebabCase } from "type-fest"; +import { ReactiveElement } from "lit"; +import { whenTransitionDone } from "../utils/dom"; + +type VisibilityProp = "open" | "closed" | "expanded" | "collapsed"; +/** One or more props used to derive whether a component is open. */ +type VisibilityPropList = readonly [VisibilityProp, ...VisibilityProp[]]; +type CustomVisibilityPropList = readonly [string, ...string[]]; + +type VisibilityState = Partial> & + ({ open: boolean } | { closed: boolean } | { expanded: boolean } | { collapsed: boolean }); + +type UseOpenCloseLifecycleHooks = { + onBeforeOpen: (host: T) => void; + onOpen: (host: T) => void; + onBeforeClose: (host: T) => void; + onClose: (host: T) => void; +}; +/** + * Interface for open/close event-emitting components. + */ +type UseOpenCloseComponent = LitElement & + VisibilityState & { + transitionProp?: KebabCase>; + transitionEl?: HTMLElement; + transitionRef?: Ref; + updateComplete: ReactiveElement["updateComplete"]; + }; + +/** + * Shared configuration for the open/close controller. + * + * - `lifecycle`: Required lifecycle hooks invoked before and after the + * visibility transition completes. + * - `shouldToggle`: Optional guard for suppressing lifecycle emission after + * open-state evaluation. + */ +type UseOpenCloseBaseOptions = { + lifecycle: UseOpenCloseLifecycleHooks; + shouldToggle?: (host: T, isOpen: boolean) => boolean; +}; + +/** + * Configuration for the open/close controller. + * + * Use ONE of the following visibility configuration modes in addition to the + * shared base options: + * + * STANDARD VISIBILITY CONTRACT + * Use `visibilityProps` when the component uses one or more of the built-in + * visibility props: `open`, `closed`, `expanded`, or `collapsed`. + * + * useOpenClose({ + * lifecycle: { + * onBeforeOpen: (host) => host.onBeforeOpen(), + * onOpen: (host) => host.onOpen(), + * onBeforeClose: (host) => host.onBeforeClose(), + * onClose: (host) => host.onClose(), + * }, + * visibilityProps: ["open"], + * shouldToggle: (host) => !host.disabled && !host.readOnly, + * }); + * + * CUSTOM VISIBILITY CONTRACT + * Use `customVisibilityProps` when the component uses component-specific + * visibility props not covered by the built-in set. In this mode, `isOpen` + * defines how to derive whether the component is currently open. + * + * useOpenClose({ + * lifecycle: { + * onBeforeOpen: (host) => host.onBeforeOpen(), + * onOpen: (host) => host.onOpen(), + * onBeforeClose: (host) => host.onBeforeClose(), + * onClose: (host) => host.onClose(), + * }, + * customVisibilityProps: ["opened"], + * isOpen: (host) => host.opened, + * }); + */ +type UseOpenCloseOptions = + | (UseOpenCloseBaseOptions & { + visibilityProps: VisibilityPropList; + }) + | (UseOpenCloseBaseOptions & { + customVisibilityProps: CustomVisibilityPropList; + isOpen: (host: T) => boolean; + }); + +/** + * Controller for managing open/close-related events. + */ +export const useOpenClose = ( + options: UseOpenCloseOptions, +): ReturnType> => + makeGenericController((component, controller) => { + const watchedProps = "visibilityProps" in options ? options.visibilityProps : options.customVisibilityProps; + + let previousOpenState = getOpenState(component); + + controller.onUpdate((changes) => { + const visibilityPropChanged = watchedProps.some((visibilityProp) => changes.has(visibilityProp)); + + if (!visibilityPropChanged) { + return; + } + + const currentOpenState = getOpenState(component); + + if (previousOpenState === currentOpenState) { + return; + } + + if (options.shouldToggle?.(component, currentOpenState) ?? true) { + void emitOpenCloseEventsAfterUpdate(component, currentOpenState); + } + + previousOpenState = currentOpenState; + }); + + function getOpenState(host: UseOpenCloseComponent): boolean { + if ("visibilityProps" in options) { + return options.visibilityProps.every((visibilityProp) => getOpenStateForProp(host, visibilityProp)); + } + + return !!options.isOpen(host as T); + } + + function getOpenStateForProp(host: UseOpenCloseComponent, visibilityProp: VisibilityProp): boolean { + if (visibilityProp === "open") { + return !!host.open; + } + + if (visibilityProp === "expanded") { + return !!host.expanded; + } + + if (visibilityProp === "closed") { + return !host.closed; + } + + return !host.collapsed; + } + + async function emitOpenCloseEventsAfterUpdate(host: UseOpenCloseComponent, isOpen: boolean): Promise { + await host.updateComplete; + + if (isOpen) { + options.lifecycle.onBeforeOpen(host as T); + } else { + options.lifecycle.onBeforeClose(host as T); + } + + await host.updateComplete; + const transitionNode = host.transitionRef?.value ?? host.transitionEl; + + if (transitionNode && host.transitionProp) { + await whenTransitionDone(transitionNode, host.transitionProp); + } + + if (isOpen) { + options.lifecycle.onOpen(host as T); + } else { + options.lifecycle.onClose(host as T); + } + } + });