Skip to content
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

Accordion - expand/collapse enhancements #2147

Open
wants to merge 8 commits into
base: main
Choose a base branch
from

Conversation

alex-ju
Copy link
Member

@alex-ju alex-ju commented Jun 7, 2024

πŸ“Œ Summary

In this PR we propose a set of enhancements to our Accordion component to accommodate the following use cases without introducing any breaking changes:

Allow consumers to implement an "expand all"/"collapse all" functionality (request reference)

  • we considered embedding the button inside the component but decided that would be too restrictive for our consumers
  • the flexible implementation also allows consumers to control the state (open/close) of each accordion item from outside the component

Example of usage

<button type="button" {{on "click" this.toggleState}}>
{{if (eq this.state "open") "Collapse all" "Expand all"}}
</button>

<Hds::Accordion @forceState={{this.state}} as |A|>
{{! accordion items }}
</Hds::Accordion>

Expose a function to close an accordion item from within its content (request reference)

Example of usage

<Hds::Accordion::Item>
  <:toggle>Item one</:toggle>
  <:content as |c|>
     <button type="button" {{on "click" c.close}}>Close</button>
  </:content>
</Hds::Accordion::Item>

πŸ› οΈ Detailed description

  • Updated the DisclosurePrimitive to allow external control of the isOpen state. We do this by introducing two private properties (_isOpen, representing the internal state and _isControlled set to true every time the @isOpen argument is changed externally). Tried to follow the concepts previously explored.
  • Updated the Accordion::Item and Accordion adding a @forceState argument (open/close) to control the state from outside the component after the initial render.
  • Updated the Accordion::Item component to forward the close action from DisclosurePrimitive.

πŸ”— External links

Jira ticket: HDS-3364


πŸ‘€ Component checklist

  • Make the type adjustments in the relevant types.ts files
  • Extend test suites to cover the new functionality
  • Test/validate the solution with consumers
  • Percy was checked for any visual regression
  • A changelog entry was added via Changesets if needed (see templates here)

πŸ’¬ Please consider using conventional comments when reviewing this PR.

Copy link

vercel bot commented Jun 7, 2024

The latest updates on your projects. Learn more about Vercel for Git β†—οΈŽ

Name Status Preview Updated (UTC)
hds-showcase βœ… Ready (Inspect) Visit Preview Jun 25, 2024 11:51am
hds-website βœ… Ready (Inspect) Visit Preview Jun 25, 2024 11:51am

Copy link
Contributor

@zamoore zamoore left a comment

Choose a reason for hiding this comment

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

Given the requirements, this approach makes sense to me.

content: [
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
close: (...args: any[]) => void;
Copy link
Contributor

Choose a reason for hiding this comment

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

Could this be unknown instead of any?

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh interesting (unknown vs. any). Was looking at this: https://devblogs.microsoft.com/typescript/announcing-typescript-3-0-rc-2/#the-unknown-type

Seems like a better choice if it works for us.

Copy link
Member Author

Choose a reason for hiding this comment

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

unknown might be a better fit although, for consistency with other functions, I would be tempted to leave it as any and review them holistically.

}

@action
onStateChange() {
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like you're passing @isOpen from the template as well.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, @isOpen can be accessed as an argument, but it's not required. The reason I pass it in from the template is for the ember render modifier to track it

Copy link
Contributor

@didoo didoo left a comment

Choose a reason for hiding this comment

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

@alex-ju added a comment that may reveal to be a blocker, have a look

Copy link
Contributor

@didoo didoo left a comment

Choose a reason for hiding this comment

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

Left a few comments and considerations.

I haven't completed the review, because I want to discuss with you the controlled/uncontrolled states (still doesn't work for me, but maybe it's how I am thinking of the problem). Let's sync later in the day

@@ -4,5 +4,5 @@
}}

<div class="hds-accordion" ...attributes>
{{yield (hash Item=(component "hds/accordion/item"))}}
{{yield (hash Item=(component "hds/accordion/item" forceState=@forceState))}}
Copy link
Contributor

Choose a reason for hiding this comment

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

playing advocate's devil: should this new argument called something like forceIsOpen (or forceOpen) and be a boolean? πŸ€”

Copy link
Contributor

Choose a reason for hiding this comment

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

another consideration: I was imagining that the forced state would be at the item level, not at the parent accordion level (the isOpen is at item level). why is it necessary at this level? one can achieve the same "collapse all/expand all" by passing the @forceState argument to all the items, plus (in that case) it's possible to control a single item independently from the others (while with the current implementation it's not possible)

Copy link
Member Author

Choose a reason for hiding this comment

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

my understanding is that our consumers want a way to manage the states of all accordion items (not individual items), hence the approach of setting it at the root and cascading it to items.

Copy link
Member Author

Choose a reason for hiding this comment

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

updated with the suggested adjustments, adding onClickToggle to DisclosurePrimitive and forwarding it to Accordion::Item

Copy link
Contributor

Choose a reason for hiding this comment

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

what about having the forceState argument as boolean (calling it something like forceIsOpen or forceOpen)? would it make sense? do we expect to have more than one type of forced state in the future?

alex-ju and others added 4 commits June 25, 2024 12:45
Co-Authored-By: Cristiano Rastelli <686239+didoo@users.noreply.github.com>
to showcase `onClickToggle`

Co-Authored-By: Cristiano Rastelli <686239+didoo@users.noreply.github.com>
Co-Authored-By: Cristiano Rastelli <686239+didoo@users.noreply.github.com>
Co-Authored-By: Cristiano Rastelli <686239+didoo@users.noreply.github.com>
@alex-ju alex-ju requested review from jgwhite and didoo June 25, 2024 12:30
Copy link
Contributor

@didoo didoo left a comment

Choose a reason for hiding this comment

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

Left a few more comments

@@ -4,5 +4,5 @@
}}

<div class="hds-accordion" ...attributes>
{{yield (hash Item=(component "hds/accordion/item"))}}
{{yield (hash Item=(component "hds/accordion/item" forceState=@forceState))}}
Copy link
Contributor

Choose a reason for hiding this comment

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

what about having the forceState argument as boolean (calling it something like forceIsOpen or forceOpen)? would it make sense? do we expect to have more than one type of forced state in the future?

`
);
// first item open at rendering
assert.dom('.hds-accordion-item__content').exists({ count: 1 });
Copy link
Contributor

Choose a reason for hiding this comment

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

being a bit over-zealous here: should we be more specific to make sure it's the first item being open?

Comment on lines +214 to +216
<:content as |c|>
<button type="button" {{on "click" c.close}}>Close</button>
</:content>
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
<:content as |c|>
<button type="button" {{on "click" c.close}}>Close</button>
</:content>
<:content as |c|>Content one</:content>

assert.dom('.hds-accordion-item__content').doesNotExist();
// toggle to open
await click('.hds-accordion-item__button');
assert.dom('.hds-accordion-item__content').exists();
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is not really testing the onClickToggle callback; probably we should have an "external state" that gets changed on onClickToggle and check that external state value?

Copy link
Contributor

Choose a reason for hiding this comment

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

To make sure I wasn't missing something, I've commented out the content of the this.set('onClickToggle', () => {}); function, and the tests are still passing.

// if the state is controlled from outside, the argument overrides the internal state
return this.args.isOpen ?? this._isOpen;
} else {
// if the state is changes internally, the internal state overrides the argument
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// if the state is changes internally, the internal state overrides the argument
// if the state changes internally, the internal state overrides the argument

return this.args.isOpen ?? this._isOpen;
} else {
// if the state is changes internally, the internal state overrides the argument
return this._isOpen !== undefined
Copy link
Contributor

Choose a reason for hiding this comment

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

in which case the value of _isOpen would be undefined? (I did a check, and to me seems it's not possible)

@@ -33,11 +35,44 @@ export interface HdsDisclosurePrimitiveSignature {
}

export default class HdsDisclosurePrimitiveComponent extends Component<HdsDisclosurePrimitiveSignature> {
@tracked isOpen = this.args.isOpen ?? false;
@tracked _isOpen = false;
@tracked _isControlled = this.args.isOpen != undefined;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
@tracked _isControlled = this.args.isOpen != undefined;
@tracked _isControlled = this.args.isOpen !== undefined;


@action
onStateChange() {
if (this.args.isOpen !== undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

if I understand correctly, we don't use this._isControlled here because this function is invoked when the value of @isOpen changes, which triggers the did-update which is run without re-rendering the component, so line 39 is not re-evaluated

if that is the case, what do you think of adding a comment to explain this "detail"? πŸ˜€ (it took me a while to get it)

if not, then maybe we can use this._isControlled as condition? would make sense (in which case TS complains, but we can cast the assignment via Boolean(this.args.isOpen) like we do at line 49

</Hds::Accordion>
</SG.Item>
</Shw::Grid>

<Shw::Text::H3>Edge cases</Shw::Text::H3>
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
<Shw::Text::H3>Edge cases</Shw::Text::H3>
<Shw::Divider @level={{2}} />
<Shw::Text::H3>Edge cases</Shw::Text::H3>

@emailnitram
Copy link

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

Successfully merging this pull request may close these issues.

None yet

6 participants