Skip to content

Conversation

eliganemtyler
Copy link
Contributor

@eliganemtyler eliganemtyler commented Dec 30, 2024

PR Checklist

Please check if your PR fulfills the following requirements:

  • Tests for the changes have been added/updated: Y
  • Docs have been added/updated: Y
  • Does this PR introduce a breaking change? N
  • I have linked any related GitHub issues to be closed when this PR is merged? Y

Describe the new behavior?

This PR adds a target-button attribute and property to the expansion panel that matches the description in the linked issue. The target-button value should match the id of an element in the document intended to toggle the panel. The target button toggles the open state of the component, and has its aria-controls set by and aria-expanded managed by the component. The element in the default slot is assigned a unique id if it doesn't already have one, to be linked to by the target button's aria-controls.

Additional information

I originally had it so that if targetButton wasn't set, the first <button> or element with button role in the header slot would be considered the target button, but I realized a developer might be putting a button with another purpose there. I also considered letting the developer add a forge-target-button selector to a header slot button (like the forge-dialog-focus selector) to make it the target button, instead of having to set both target-button and the button id, but I wasn't sure if that would be overcomplicating things.

@eliganemtyler eliganemtyler requested a review from a team as a code owner December 30, 2024 21:39
Copy link

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@github-actions github-actions bot added the minor Increment the minor version when merged label Dec 30, 2024
@eliganemtyler
Copy link
Contributor Author

eliganemtyler commented Dec 30, 2024

It looks like I don't have permission to link the issue to this PR (no gear icons on development, labels, etc), but it's this one: #774

Let me know what you think!

@eliganemtyler eliganemtyler force-pushed the feat/expansion-panel-aria branch 2 times, most recently from e2a741b to c64b2e0 Compare December 31, 2024 16:50
@DRiFTy17 DRiFTy17 added the skip-release Preserve the current version when merged label Jan 3, 2025
Copy link
Collaborator

@DRiFTy17 DRiFTy17 left a comment

Choose a reason for hiding this comment

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

The changes look great overall. This should really assist developers with convenience, and we'll want to make sure we let everyone know when it's available.

I left some comments below of things that jump out at me, let me know if something doesn't make sense and feel free to push back on anything! I'll do a more in depth review/testing of usage when the next set of changes are pushed up.

Comment on lines 131 to 132
this._targetButton = value;
this._adapter.setHostAttribute(EXPANSION_PANEL_CONSTANTS.attributes.TARGET_BUTTON, this._targetButton);
Copy link
Collaborator

@DRiFTy17 DRiFTy17 Jan 3, 2025

Choose a reason for hiding this comment

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

We will need additional logic here to remove any listeners from a previously attached element in case the trigger button changes dynamically and one is already set at that time. We should write a test case for this scenario as well.

Copy link
Contributor Author

@eliganemtyler eliganemtyler Jan 21, 2025

Choose a reason for hiding this comment

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

This part of the "should update old and new triggers on trigger change" test will test the event listeners being removed when the trigger element changes:

trigger1.click();
expect(expansionPanel.open).to.be.true;

expansionPanel.trigger = 'button-id2';
expect(trigger1.getAttribute('aria-controls')).to.be.null;
expect(trigger1.getAttribute('aria-expanded')).to.be.null;
trigger1.click();
expect(expansionPanel.open).to.be.true; //expansion panel stays open when old trigger is clicked

@eliganemtyler
Copy link
Contributor Author

Thanks so much for the review @DRiFTy17! I should have some time in the next couple weeks to work through these suggestions.

@eliganemtyler eliganemtyler force-pushed the feat/expansion-panel-aria branch 3 times, most recently from dca8cfa to 314e749 Compare January 21, 2025 21:42
@eliganemtyler
Copy link
Contributor Author

My recent commits hopefully address all the points you raised @DRiFTy17.

In addition to better handling cases where the trigger id changes and points to another element, I also handle cases where the slotted content changes with a slotchange listener. I saw that in some components a slotchange listener gets manually removed on component destroy (e.g. button-area) and in some it doesn't (e.g. app-bar). My assumption is that it's fine the latter way because the listeners would get cleaned up by js once the component is destroyed but let me know if I'm wrong.

Also, I wanted to make sure document reference in _tryLocateTriggerElement() was okay. I was originally using this._component.getRootNode() there, but by the time it was called during the disconnect hook it didn't work. I saw precedent for using document in the skip-link component.

Thanks!

Copy link
Collaborator

@DRiFTy17 DRiFTy17 left a comment

Choose a reason for hiding this comment

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

Sorry for the delay, but here's another quick review. Things are looking great so far!

@@ -20,36 +20,45 @@ It's common to compose an expansion panel with a card component to provide a mor

> **Tip:** You can provide a `<forge-open-icon>` to show the open state of the expansion panel, and the icon will be rotated when the panel is opened/closed.

## Target Button

A button in the header slot of the expansion panel will automatically control toggling the `open` state of the panel.
Copy link
Collaborator

Choose a reason for hiding this comment

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

May be worth adding a note about ensuring to control the existence of the trigger button and the panel in the DOM at the same time to avoid any hard to debug issues when it comes to async/incremental rendering.

These types of scenarios are why we may want to consider adding a triggerElement property to allow devs to set the instance directly in cases where timing can be an issue.

Eventually we can invert this process by using the Invoker Commands API with a custom command so that the button will instead notify the panel rather than the other way around which will simplify things a lot.

@eliganemtyler eliganemtyler force-pushed the feat/expansion-panel-aria branch from 314e749 to 0727b4c Compare February 19, 2025 21:51
@eliganemtyler
Copy link
Contributor Author

eliganemtyler commented Feb 19, 2025

Hey @DRiFTy17, I've incorporated your feedback into these latest changes (everything from "remove unused import" on).

I added the triggerElement property. I used the anchor and anchorElement pattern in tooltip as a reference, but it was a little different since tooltip sets and maintains a reference to the anchor element, whereas the expansion panel always looks for the trigger element dynamically by its id each time it needs it.

I also considered the case where both trigger (id) and triggerElement (element ref) are set (which would be an incorrect use). I decided to have the component simply throw an error in that case, instead of worrying about which should take precedent.

Copy link
Collaborator

@DRiFTy17 DRiFTy17 left a comment

Choose a reason for hiding this comment

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

@eliganemtyler It's coming along nicely, this iteration of changes look to be much better IMO. I pulled this down and gave it some thorough testing and made a few notes below. We're getting there!

}

export class ExpansionPanelAdapter extends BaseAdapter<IExpansionPanelComponent> implements IExpansionPanelAdapter {
private _headerElement: HTMLElement;
private _contentElement: HTMLElement;
private _innerElement: HTMLElement;
private _headerSlotElement: HTMLSlotElement;
Copy link
Collaborator

Choose a reason for hiding this comment

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

It looks like this is unused, let's go ahead and remove it.

this._syncTrigger();
}

private _syncTrigger(): void {
Copy link
Collaborator

Choose a reason for hiding this comment

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

We may want to handle the scenario where the trigger element is detached from the DOM dynamically and release our internal reference to it each time this method runs by checking if the trigger element ref is not connected.

Comment on lines 170 to 172
private _throwTriggerError(): void {
throw new Error('trigger and triggerElement cannot both be set.');
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I honestly wouldn't even bother throwing an exception in this scenario... I think if someone is choosing the more explicit triggerElement property to control it then it should take precedence IMO. It should be very rare if someone uses these two together, and it's probably ok to assume the element reference is of higher priority. I could be convinced otherwise though if there's good reason to throw.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good to me!


private _tryLocateTriggerElement(id: string): HTMLElement | null {
if (id) {
const triggerEl = document.getElementById(id);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Other components may have this same issue, but we should instead get the root node to ensure it is scoped to the specific DOM tree given the potential for other shadow root boundaries (if we were to use the expansion panel within the shadow of another element then it wouldn't work):

const triggerEl = this._component.getRootNode().getElementById(id);

Copy link
Contributor Author

@eliganemtyler eliganemtyler Feb 20, 2025

Choose a reason for hiding this comment

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

The reason I have document here and not getRootNode() is because when getRootNode() gets called after component disconnect, you get a rootNode.getElementById is not a function error, since getRootNode() just finds the component itself (I guess because at this point the component is disconnected from the document).

The difference between expansion panel trigger and tooltip anchor in this regard is that expansion panel needs to do something with the trigger element (removing the event listener) on destroy. Unfortunately there isn't something like a beforeDisconnectCallback() hook.

What do you think about:

const triggerEl = deepQuerySelectorAll(document.documentElement, `#${id}`)[0] as HTMLElement;

This solves the shadow root problem you brought up, but maybe raises performance issues?

Or, maybe a solution is to set a reference to the root node on init and use that instead of this._component.getRootNode() dynamically? Is it a safe assumption (or rare enough to be ok) that the component's root node won't have changed since init?

Copy link
Collaborator

Choose a reason for hiding this comment

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

If this code attempts to run while the element is disconnected I would just not do anything. It will be updated when it's connected/disconnected anyway. This is why this pattern is very finicky and can introduce memory leaks when holding onto element references that are not owned by the element itself. Once the element is disconnected from the DOM we need to drop the references (set them to undefined so the GC can clean them up) and then things like this just won't do anything until place in the DOM.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Forgot to mention the other thing the expansion panel is doing when it disconnects is removing the ARIA attributes from the trigger button.

So your suggestion is to leave const triggerEl = document.getElementById(id); as is but add this._adapter.triggerElementRef = undefined to destroy() in core? And there will just still be that limitation with the shadow root boundaries.

Copy link
Contributor Author

@eliganemtyler eliganemtyler Feb 21, 2025

Choose a reason for hiding this comment

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

Remembering now the shadow root boundary issue is kind of a no-go. It makes the trigger feature unusable when the expansion panel is used within another component's shadow root. What do you think about this:

  private _tryLocateTriggerElement(id: string): HTMLElement | null {
    if (id) {
      if (this._component.getRootNode() !== this._component) {
        return (this._component.getRootNode() as Document | ShadowRoot).getElementById(id);
      } else {
        return deepQuerySelectorAll(document.documentElement, `#${id}`)[0] as HTMLElement;
      }
    } else {
      return null;
    }
  }

It limits the less performant deepQuerySelectorAll call just to when the component disconnects.

}

private get _slottedContentElement(): Element | undefined {
return this._defaultSlotElement.assignedElements()[0];
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should use assignedElements({ flatten: true }) here instead to ensure that it handles composed slot elements (slots acting as passthrough slots on potential parent elements).

Copy link
Contributor Author

@eliganemtyler eliganemtyler Feb 20, 2025

Choose a reason for hiding this comment

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

Thanks, it looks like I misunderstood how flatten worked. I thought if the slotted element was for example a <forge-scaffold>, then assignedElements({ flatten: true })[0] was going to return what was in the header slot of <forge-scaffold>, but I see now that's not how it works.

@eliganemtyler
Copy link
Contributor Author

Hey @DRiFTy17, the component is now back to largely mirroring how the tooltip manages its anchor element after our conversation. It now sets and keeps a reference to a _triggerElement by trigger id or triggerElement ref. Previously, the component would either use a triggerElement ref, or dynamically get the element each time by the trigger id if triggerElement wasn't set. The reason for this change is so the component always has a ref to the trigger element on disconnect (since previously rootNode.getElementById() didn't work on disconnect).

This means that the explicit triggerElement ref doesn't take precedence over trigger anymore, because the component doesn't have an obvious way of knowing if _triggerElement was set by trigger or triggerElement. So the property most recently set would have effect. Also like the tooltip and its anchor, it also means the component won't repeatedly check that an element still matches the trigger id -- the component doesn't know whether it's supposed to use an id or explicit ref.

Overall for the sake of getting the feature out, this is at least as robust as the tooltip, even if there are better ways of handling this. Some ideas would be managing a usingTriggerRef state? Or managing two element refs _triggerElementByRef _triggerElementById?

The component also now sets _triggerElement to undefined on disconnect, or if syncTrigger runs and finds that the _triggerElement is disconnected (in which case it will fall back to a trigger id if one is set).

Copy link
Collaborator

@DRiFTy17 DRiFTy17 left a comment

Choose a reason for hiding this comment

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

LGTM. Thanks for all the work on this!

Once this is merged, could you then create a PR on the forge-angular repo to regenerate the Angular proxies?

@DRiFTy17 DRiFTy17 merged commit 9cf4491 into tyler-technologies-oss:main Mar 19, 2025
7 checks passed
Copy link
Contributor

github-actions bot commented Apr 3, 2025

🚀 PR was released in v3.8.0 🚀

@github-actions github-actions bot added the released This issue/pull request has been released. label Apr 3, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
minor Increment the minor version when merged released This issue/pull request has been released. skip-release Preserve the current version when merged
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants