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

feat(cdk-experimental/listbox): selection logic and testing for listbox. #19690

Merged
merged 35 commits into from
Jun 26, 2020

Conversation

nielsr98
Copy link
Contributor

Added the basic logic to keep track of a selected state of options and to toggle that state by clicking on them. The selected state of an option is tied to aria-selected. The CdkListbox also handles providing unique optionIds to its child options. Currently, logic to toggle multi-select in the listbox is not yet included, so by default the listbox has multi-select enabled. Tests were included to listbox. Feedback on testing style is appreciated.

@nielsr98 nielsr98 requested a review from jelbourn as a code owner June 18, 2020 16:03
@googlebot googlebot added the cla: yes PR author has agreed to Google's Contributor License Agreement label Jun 18, 2020
@nielsr98 nielsr98 requested a review from scriby June 18, 2020 16:03
@nielsr98 nielsr98 added the target: minor This PR is targeted for the next minor release label Jun 18, 2020
Comment on lines 89 to 94
this._options.toArray().forEach(option => {
const optionId = option.getOptionId();
if ($event.target instanceof Element &&
optionId === $event.target?.getAttribute('data-optionid')) {
this._updateSelectedOption(option);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I was thinking about this and had one idea for how we can get rid of the QueryList. I think it's a little unorthodox for Angular, but could still be worth considering if the performance is significantly better.

The basic idea is just to store a reference to the Angular component on the option's DOM element itself. Then, in this click handler we can just access it via $event.target._ngComponent without having to search for it again via the loop.

To store the component reference, I think it'd be as simple as this:

constructor(private readonly elementRef: ElementRef) {}

ngOnInit() {
  this.elementRef.nativeElement._ngComponent = this;
}

This change isn't required to complete the review, I'm just raising it up for discussion.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Interesting idea. I was planning on doing some performance comparisons and testing this weekend and refactor it afterwards.

Copy link
Member

@jelbourn jelbourn Jun 18, 2020

Choose a reason for hiding this comment

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

One problem here: the option element may not actually be the click target; the option may (likely will) contain other elements. This would mean you'd need to walk up the DOM to get the option. You could do something here like:

updateSelection(event: MouseEvent) {
  const optionElement = getClosestOption(event.target);
  const selectedOption = this._options.find(o => o.id === optionElement.id);
  ...
}

As for attaching the component instance to the DOM- it's not particularly aligned with Angular's development model. The main thing I'd worry about is leaking memory since the DOM element and the component would reference each other.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Agreed on the solution being weird in Angular (although I'd also argue Angular is a little deficient for handling this particular use case), but I don't think memory leaking is an issue as long as the reference is unset in ngOnDestroy.

You might be remembering the circular reference issue from IE6/IE7 (https://stackoverflow.com/questions/10092619/precise-explanation-of-javascript-dom-circular-reference-issue), but this particular one hasn't been an issue for a while :)

Copy link
Member

Choose a reason for hiding this comment

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

I guess I'm showing my age

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Another method is to have the option listen for the click and toggle its selected state on click. Then it uses an injected reference to its parent listbox to trigger the listbox's selectionChange event emitter. However I think this may go against the "read not write" relationship the option should have with its parent listbox.

src/cdk-experimental/listbox/listbox.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.spec.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.spec.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.spec.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.spec.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.spec.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.spec.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.ts Outdated Show resolved Hide resolved
Comment on lines 89 to 94
this._options.toArray().forEach(option => {
const optionId = option.getOptionId();
if ($event.target instanceof Element &&
optionId === $event.target?.getAttribute('data-optionid')) {
this._updateSelectedOption(option);
}
Copy link
Member

@jelbourn jelbourn Jun 18, 2020

Choose a reason for hiding this comment

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

One problem here: the option element may not actually be the click target; the option may (likely will) contain other elements. This would mean you'd need to walk up the DOM to get the option. You could do something here like:

updateSelection(event: MouseEvent) {
  const optionElement = getClosestOption(event.target);
  const selectedOption = this._options.find(o => o.id === optionElement.id);
  ...
}

As for attaching the component instance to the DOM- it's not particularly aligned with Angular's development model. The main thing I'd worry about is leaking memory since the DOM element and the component would reference each other.

src/cdk-experimental/listbox/listbox.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.spec.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.ts Outdated Show resolved Hide resolved
@nielsr98
Copy link
Contributor Author

Updated click handling. Feedback addressed.

Copy link
Member

@jelbourn jelbourn left a comment

Choose a reason for hiding this comment

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

Looks like there are still a few unresolved comments from the previous round

src/cdk-experimental/listbox/listbox.spec.ts Show resolved Hide resolved

it('should generate a unique optionId for each option', () => {
for (const option of options) {
expect(option.injector.get<CdkOption>(CdkOption).getOptionId()).toMatch(/cdk-option-\d+/);
Copy link
Member

Choose a reason for hiding this comment

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

optional: you could also test that all of the IDs are different

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In making this test, I realized the .includes() method for arrays isn't supported. I think it came with ES7. Is this expected for the current Angular version?

src/cdk-experimental/listbox/listbox.spec.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.spec.ts Outdated Show resolved Hide resolved
src/cdk-experimental/listbox/listbox.spec.ts Show resolved Hide resolved
this.setOptionId(`cdk-option-${_uniqueIdCounter++}`);
}

_onClickUpdateSelected() {
Copy link
Member

Choose a reason for hiding this comment

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

I would combine this method and toggleSelected into a single method just called toggle

toggle() {
   this.selected = !this.selected;
  this.listbox._emitChangeEvent(this);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So for a function as simple as this one, I suppose its not necessary to have a function name that is more descriptive than just "toggle"

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, my philosophy is to name methods for what they do rather than how they're called

@nielsr98
Copy link
Contributor Author

New feedback addressed.

let listboxInstance: CdkListbox;
let options: DebugElement[];
let optionInstances: CdkOption[];
let optionElements: Element[];
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
let optionElements: Element[];
let optionElements: HTMLElement[];

You can typically be a little bit more specific with these and specify HTMLElement


it('should update selected option on click event', () => {
let selectedOptions = optionInstances.filter(option => option.selected);
spyOn(listboxInstance, '_emitChangeEvent');
Copy link
Member

Choose a reason for hiding this comment

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

Rather than setting up a spy on the internal listbox method, you can have your ListboxWithCdkOptions component below add a (selectionChange) handler to the listbox and assert that that's called. Generally you want to avoid having the test know about the internals of the component unless there's no other practical way to test something.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok I see. Will change.

<div cdkOption>Stasis</div>
</div>`
})
class ListboxWithCdkOptions {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
class ListboxWithCdkOptions {
class ListboxWithOptions {

can also omit Cdk form the option part

@Directive({
selector: '[cdkOption]',
exportAs: 'cdkOption',
host: {
role: 'option',
'(click)': 'toggle()',
'[attr.aria-selected]': '_selected || null',
'[attr.optionid]': '_optionid',
Copy link
Member

Choose a reason for hiding this comment

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

It does need to be the native id property since aria-activedescendant relies on id. You can look at MatCheckbox for an example how we do do this, but by making id an @Input and assigning it an initial value, you ensure that it always has an ID while also letting the user set one if they need to for some reason (but people generally don't want to, that's why they're using a common component).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I see I think select does that as well. I'll fix this.

/** A query list containing all CdkOption elements within this listbox */
@ContentChildren(CdkOption, {descendants: true}) _options: QueryList<CdkOption>;

@Output() readonly selectionChange: EventEmitter<CdkOption> = new EventEmitter<CdkOption>();
Copy link
Member

Choose a reason for hiding this comment

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

I forgot if I mentioned this earlier, but in a follow-up we'll want to change this from emitting CdkOption to emitting an event interface (something like ListboxSelectionChangeEvent)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Alright sounds good.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I looked into it and it seemed pretty straightforward so I rolled it into a commit in this PR. If I didn't implement it right or you think it should go in separately I can remove it.

@nielsr98
Copy link
Contributor Author

Addressed new feedback. Ready for review.

Copy link
Member

@jelbourn jelbourn left a comment

Choose a reason for hiding this comment

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

Okay, this should be the last last comments

</div>`
})
class ListboxWithOptions {
onSelectionChange(_option: CdkOption) {}
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 fine for now since we're going to change the event type later anyway, but you could just store the selection here and check that in the test rather than spying on the methods
(it's always better to avoid spies when you can avoid it, since they often end up just testing implementations details instead of intended functionality)

@Directive({
selector: '[cdkOption]',
exportAs: 'cdkOption',
host: {
role: 'option',
'(click)': 'toggle()',
'[attr.aria-selected]': 'selected || null',
'[attr.id]': 'id',
Copy link
Member

Choose a reason for hiding this comment

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

This can just be [id]

Comment on lines 49 to 54
get id(): string {
return this._id;
}
set id(value: string) {
this._id = value || this._uniqueid;
}
Copy link
Member

Choose a reason for hiding this comment

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

I think this could be simplified to

@Input() id = `cdk-opt-${nextId++}`;

If the user specifies a value, it will override write this after this initial value is set, and you won't need the _uniqueId property at all.

}

let nextId = 0;
Copy link
Member

Choose a reason for hiding this comment

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

I would typically put this above the class where it's used

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah true I used to use it in Listbox and neglected moving it.

@nielsr98
Copy link
Contributor Author

Ready for review

Copy link
Member

@jelbourn jelbourn left a comment

Choose a reason for hiding this comment

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

LGTM

@jelbourn jelbourn added lgtm action: merge The PR is ready for merge by the caretaker labels Jun 26, 2020
@jelbourn jelbourn merged commit 2a97418 into angular:master Jun 26, 2020
@angular-automatic-lock-bot
Copy link

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Jul 27, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
action: merge The PR is ready for merge by the caretaker cla: yes PR author has agreed to Google's Contributor License Agreement target: minor This PR is targeted for the next minor release
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants