Skip to content

refactor: add enhanced useOpenClose controller to phase out toggleOpenClose#14040

Open
Elijbet wants to merge 2 commits intodevfrom
elijbet/useOpenCloseController
Open

refactor: add enhanced useOpenClose controller to phase out toggleOpenClose#14040
Elijbet wants to merge 2 commits intodevfrom
elijbet/useOpenCloseController

Conversation

@Elijbet
Copy link
Contributor

@Elijbet Elijbet commented Mar 9, 2026

Related Issue: #11305

Summary

Add enhanced useOpenClose controller to phase out toggleOpenClose.

@Elijbet Elijbet changed the title Elijbet/use open close controller refactor: add enhanced useOpenClose controller to phase out toggleOpenClose Mar 9, 2026
@Elijbet
Copy link
Contributor Author

Elijbet commented Mar 9, 2026

This is an alternative solution to refactor: add useOpenClose controller#11309

  • 11309 is an imperative controller: it exposes afterToggle() for the component to call it. This option is reactive: it watches the component’s open state during updates and runs automatically on changes.
  • 11309 delegates all lifecycle timing and transition handling to onToggleOpenCloseComponent(...). This option implements the lifecycle itself (before-open/close → wait for update → wait for transition → open/close).
  • 11309 takes a configuration object up front (transition element/prop, open prop name, and callbacks). This options reads those values directly off the host component at runtime.

@Elijbet Elijbet force-pushed the elijbet/useOpenCloseController branch from 53a0dcd to 4a042aa Compare March 10, 2026 16:41
@github-actions github-actions bot added the refactor Issues tied to code that needs to be significantly reworked. label Mar 10, 2026
@Elijbet Elijbet requested a review from jcfranco March 10, 2026 16:44
@Elijbet Elijbet marked this pull request as ready for review March 10, 2026 16:45
Copy link
Member

@driskull driskull left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good @Elijbet.

I'd like to know what @jcfranco thinks about having a controller per property vs a controller that accepts multiple properties like open and expanded.

@Elijbet can you get a review from copilot for this PR?

onClose: () => void;
transitionEl?: HTMLElement;
transitionRef?: Ref<HTMLElement>;
updateComplete: ReactiveElement["updateComplete"];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, do you need this at all? LitElement already extends ReactiveElement it seems

});

function getOpenState(host: UseOpenCloseComponent): boolean {
return visibilityProps.every((visibilityProp) => getOpenStateForProp(host, visibilityProp));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Elijbet these would need to be separate states. open/expanded/closed/collapsed are all different states that should be managed separately.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Components shouldn't have both open and closed nor should they have expanded and collapsed but they could have open and expanded or open and collapsed. etc.

import type { KebabCase } from "type-fest";
import { ReactiveElement } from "lit";
import { whenTransitionDone } from "../utils/dom";

Copy link
Member

@driskull driskull Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Elijbet we may want to introduce mutually exclusive state types:

type ExclusiveState<A extends string, B extends string> =
  | ({ [K in A]?: boolean } & { [K in B]?: never })
  | ({ [K in B]?: boolean } & { [K in A]?: never });

export type OpenCloseState = ExclusiveState<"open", "closed">;

export type ExpandedCollapseState = ExclusiveState<"expanded", "collapsed">;

export type OpenCloseExpandedCollapseState = OpenCloseState & ExpandedCollapseState;

New exported types:

OpenCloseState: open or closed (not both)
ExpandedCollapseState: expanded or collapsed (not both)
OpenCloseExpandedCollapseState: combines both constraints

Copy link
Member

@jcfranco jcfranco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good progress, @Elijbet!

/**
* Interface for components using the open/close controller.
*/
export type UseOpenCloseComponent = LitElement &
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this isn't used externally, let's not export it for now.

controller.onUpdate(() => {
const currentOpenState = getOpenState(component);

if (previousOpenState !== currentOpenState) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you look at all components using toggleOpenClose and see whether this can be applied only when the open prop changes in willUpdate/onUpdate? Some components also consider additional props (e.g., disabled) when deciding whether to call the util.

* Controller for managing open/close lifecycle and transitions.
*/
export const useOpenClose = <T extends UseOpenCloseComponent>(
visibilityProps: VisibilityPropList,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To future-proof, can you refactor the params to be an object of options instead of a single option?

};

/**
* Controller for managing open/close lifecycle and transitions.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mainly used for open/close-related events.

export type UseOpenCloseComponent = LitElement &
VisibilityState & {
transitionProp?: KebabCase<Extract<keyof CSSStyleDeclaration, string>>;
onBeforeOpen: () => void;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you refactor the event hooks so they’re specified via the controller options? This would help keep controller related props together and discourage calling the methods separately.

({ open: boolean } | { closed: boolean } | { expanded: boolean } | { collapsed: boolean });

/**
* Interface for components using the open/close controller.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Interface for open/close event-emitting components.

return !host.collapsed;
}

async function handleOpenClose(host: UseOpenCloseComponent, isOpen: boolean): Promise<void> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you rename this to better express the intent? On first read, it’s not obvious what it does.


const openingControlledPromise = createControlledPromise<void>();
const getAnimationsSpy = vi.spyOn(component.transitionEl, "getAnimations");
getAnimationsSpy.mockImplementation(() => [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you see if real animations can be used? openCloseComponent.browser.spec.tsx had to mock because they were not available in the environment.

return visibilityProps.every((visibilityProp) => getOpenStateForProp(host, visibilityProp));
}

function getOpenStateForProp(host: UseOpenCloseComponent, visibilityProp: VisibilityProp): boolean {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might need to be configured per component via the controller options. For example, dialog uses opened, which is internal and not covered here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

refactor Issues tied to code that needs to be significantly reworked.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants