-
Notifications
You must be signed in to change notification settings - Fork 23
Feat/expansion panel target button #775
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat/expansion panel target button #775
Conversation
|
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! |
e2a741b
to
c64b2e0
Compare
There was a problem hiding this 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.
this._targetButton = value; | ||
this._adapter.setHostAttribute(EXPANSION_PANEL_CONSTANTS.attributes.TARGET_BUTTON, this._targetButton); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
Thanks so much for the review @DRiFTy17! I should have some time in the next couple weeks to work through these suggestions. |
dca8cfa
to
314e749
Compare
My recent commits hopefully address all the points you raised @DRiFTy17. In addition to better handling cases where the Also, I wanted to make sure Thanks! |
There was a problem hiding this 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. |
There was a problem hiding this comment.
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.
314e749
to
0727b4c
Compare
Hey @DRiFTy17, I've incorporated your feedback into these latest changes (everything from "remove unused import" on). I added the I also considered the case where both |
There was a problem hiding this 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; |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
private _throwTriggerError(): void { | ||
throw new Error('trigger and triggerElement cannot both be set.'); | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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);
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
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 This means that the explicit 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 The component also now sets |
There was a problem hiding this 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?
🚀 PR was released in |
PR Checklist
Please check if your PR fulfills the following requirements:
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. Thetarget-button
value should match the id of an element in the document intended to toggle the panel. The target button toggles theopen
state of the component, and has itsaria-controls
set by andaria-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'saria-controls
.Additional information
I originally had it so that if
targetButton
wasn't set, the first<button>
or element withbutton
role in theheader
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 aforge-target-button
selector to aheader
slot button (like theforge-dialog-focus
selector) to make it the target button, instead of having to set bothtarget-button
and the buttonid
, but I wasn't sure if that would be overcomplicating things.