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

Cdk listbox: multipleselect support and active descendant #19929

Merged
merged 23 commits into from
Jul 16, 2020

Conversation

nielsr98
Copy link
Contributor

@nielsr98 nielsr98 commented Jul 9, 2020

Added support for toggling whether listbox can have multiple options selected. Also added aria active descendant support and the ability to let the user choose whether to use active descendant or to let the focus follow the active item.

@nielsr98 nielsr98 requested a review from jelbourn as a code owner July 9, 2020 19:32
@nielsr98 nielsr98 added the target: minor This PR is targeted for the next minor release label Jul 9, 2020
@googlebot googlebot added the cla: yes PR author has agreed to Google's Contributor License Agreement label Jul 9, 2020
@nielsr98 nielsr98 requested a review from scriby July 9, 2020 21:29
@@ -89,19 +99,46 @@ export class CdkOption implements ListKeyManagerOption, Highlightable {
this._active = false;
}

/** Applies focus to the option. */
focus() {
console.log('focusing option');
Copy link
Collaborator

Choose a reason for hiding this comment

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

Leftovers

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, thanks.

private _multiple: boolean = false;
private _useActiveDescendant: boolean = true;
private _activeOption: CdkOption;
private readonly _destroy = new Subject<void>();
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this is supposed to be a ReplaySubject(1)?

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'm not sure what the difference is. I'll look into it.

Copy link
Member

Choose a reason for hiding this comment

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

We don't actually use ReplaySubject anywhere in the library, I'd err on not pulling in the extra code unless we wanted to do this more broadly

ngAfterContentInit() {
this._listKeyManager = new ActiveDescendantKeyManager(this._options)
.withWrap().withVerticalOrientation().withTypeAhead();

this._listKeyManager.change.subscribe(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This one should also have takeUntil(this._destroy$)?

this._selectionModel.changed.pipe(takeUntil(this._destroy))
.subscribe((event: SelectionChange<CdkOption>) => {

event.added.forEach((option: CdkOption) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you know if for/of loops should be used here? That's the Google internal style

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I've used them elsewhere, I forgot for this one. Thanks for the catch.

this._activeOption = this._listKeyManager.activeItem;
this._activeOption.activate();

console.log('here in update active option');
Copy link
Collaborator

Choose a reason for hiding this comment

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

Leftovers

template: `
<div cdkListbox
[useActiveDescendant]="isActiveDescendant"
>
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure which is the standard, but looks like this closing tag is on the next line, but in the previous template it's on the previous line

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I think it should be on the previous line.

@Component({
template: `
<div cdkListbox
[multiple]="isMultiselectable"
Copy link
Collaborator

Choose a reason for hiding this comment

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

There's an extra space here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually I think the (selectionChange) on the next line needs a space.

expect(option.getAttribute('aria-selected')).toBe('true');
}

expect(fixture.componentInstance.changedOption).toBeDefined();
Copy link
Collaborator

Choose a reason for hiding this comment

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

I typically avoid toBeDefined since it returns true for "null", which can be a little confusing. ".not.toBe(undefined);" is less mistakable. May not matter for this usage, up to you.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since I am checking changedOption.id directly after, do you think it is even necessary to check that changedOption is defined?

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.

Got part of the way through before my next meeting, will do another pass in a bit

Comment on lines 129 to 136
for (let i = 0; i < element.children.length; i++) {
const node = element.children[i];
if (this._isIcon(node)) {
element.removeChild(node);
} else {
this._removeIcons(node);
}
}
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
for (let i = 0; i < element.children.length; i++) {
const node = element.children[i];
if (this._isIcon(node)) {
element.removeChild(node);
} else {
this._removeIcons(node);
}
}
for (const icon of Array.from(element.querySelectorAll('mat-icon, .material-icons'))) {
icon.parentNode.removeChild(icon);
}

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 also add a TODO in the code to make the matching for ligature icons configurable

private _multiple: boolean = false;
private _useActiveDescendant: boolean = true;
private _activeOption: CdkOption;
private readonly _destroy = new Subject<void>();
Copy link
Member

Choose a reason for hiding this comment

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

We don't actually use ReplaySubject anywhere in the library, I'd err on not pulling in the extra code unless we wanted to do this more broadly

ngAfterContentInit() {
this._listKeyManager = new ActiveDescendantKeyManager(this._options)
.withWrap().withVerticalOrientation().withTypeAhead();

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 break up the content of ngAfterContentInit here to another function (or multiple functions). In general, when you have a lot of code in a lifecycle method, there's nothing that communicates what the purpose/intent of that code is, only when it gets called.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure that makes sense.

Comment on lines 188 to 193
if (this.multiple && !value) {
this.setAllSelected(false);
} else if (!this.multiple && value) {
this._selectionModel = new SelectionModel<CdkOption>(value, this._selectionModel.selected);
}
this._multiple = coerceBooleanProperty(value);
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 probably move this code into its own function

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah will do.

return this._multiple;
}
set multiple(value: boolean) {
if (this.multiple && !value) {
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 add a comment that explains the intent behind clearing the selection from going from multiple to single

}

/** Remove any child from the given element which can be identified as an icon. */
private _removeIcons(element: Element) {
Copy link
Member

Choose a reason for hiding this comment

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

This could be a module-level function rather than a member on Option

return this._useActiveDescendant;
}
set useActiveDescendant(shouldUseActiveDescendant: boolean) {
this._useActiveDescendant = shouldUseActiveDescendant;
Copy link
Member

Choose a reason for hiding this comment

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

coerceBooleanProperty here

private _multiple: boolean = false;
private _useActiveDescendant: boolean = true;
private _activeOption: CdkOption;
private readonly _destroy = new Subject<void>();
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
private readonly _destroy = new Subject<void>();
private readonly _destroyed = new Subject<void>();

Comment on lines 287 to 289
if (this._useActiveDescendant
&& this._listKeyManager && this._listKeyManager.activeItem) {
return this._listKeyManager.activeItem.id;
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
if (this._useActiveDescendant
&& this._listKeyManager && this._listKeyManager.activeItem) {
return this._listKeyManager.activeItem.id;
return this._useActiveDescendant ? this._listKeyManager?.activeItem?.id : null;

}

/** Updates the activeOption and the active and focus properties of the option. */
updateActiveOption() {
Copy link
Member

Choose a reason for hiding this comment

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

This looks like it should be a private method?

}

/** Toggles the selected state, emits a change event through the injected listbox. */
toggle() {
if (!this._isInteractionDisabled()) {
this.selected = !this.selected;
this.listbox._emitChangeEvent(this);
this.listbox._updateSelectionModel(this);
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 probably something we want to avoid, since now the option is mutating the listbox state. I would change this in one of two ways:

  • Clicking the option emits on a stream, which the listbox listens to and updates its own state
  • Handle clicks completely in the listbox instead of on the option

@nielsr98
Copy link
Contributor Author

Feedback addressed. Ready for review.

/** Returns true if the option or listbox are disabled, and false otherwise. */
_isInteractionDisabled(): boolean {
return (this.listbox.disabled || this._disabled);
}

private _emitSelectionChange(isUserInput = false) {
this.selectionChange.emit(new OptionSelectionChangeEvent(this, isUserInput));
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 making this a concrete type, I would make this event type an interface and just return an object literal here that conforms to that interface. This gives us more flexibility to change the event object over time.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay makes sense. I'm attempting this but might not be exactly what you're suggesting. Let me know if it needs changing when I push it.

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 guess this makes sense for the Listbox event as well. I'm adding it to my next push but if you think that's a bad idea let me know.

src/cdk-experimental/listbox/listbox.ts Show resolved Hide resolved
Comment on lines 186 to 189
* Whether the listbox allows multiple options to be selected.
* When multiple switches from true to false, all options are deselected.
* This is done rather than randomly choosing one of the selected options
* to remain selected.
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
* Whether the listbox allows multiple options to be selected.
* When multiple switches from true to false, all options are deselected.
* This is done rather than randomly choosing one of the selected options
* to remain selected.
* Whether the listbox allows multiple options to be selected.
* If `multiple` switches from `true` to `false`, all options are deselected.

I would remove the rationale for the change behavior from the JsDoc description and instead put it in an inline comment in the setter. Also, you would use the word "arbitrarily" here rather than "randomly".

}
}

private _handleMultipleSwitch(value: boolean) {
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
private _handleMultipleSwitch(value: boolean) {
/** Updates option selection state for when the `multiple` selection option changes. */
private _updateSelectionForMultiSelectionChange(updatedMultiple: boolean) {

I would change the method name here to be more explicit about what the function is doing, and add a JsDoc since "multiSelection" is potentially ambiguous (e.g. it could be interpreted as multiple options changing at once).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I had a hard time thinking of a concise name but you're right that its better to just make it clear.

this._updateSelectionModel(option);
}

console.log(option.selected);
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops.

Comment on lines 242 to 243
private _initSelectionChange() {
this.optionSelectionChanges = defer(() => {
Copy link
Member

Choose a reason for hiding this comment

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

Does this need to be a function that gets called, or could optionSelectionChanges be set just as part of its definition?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah it can be part of the definition. I can also make it readonly then. I'll change.

Comment on lines 331 to 333
if (previouslyActive) {
previouslyActive.deactivate();
}
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
if (previouslyActive) {
previouslyActive.deactivate();
}
previouslyActive?.deactivate();

});
});

describe('multiselectable tests', () => {
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
describe('multiselectable tests', () => {
describe('with multiple selection', () => {

Prefer naming describe sub-blocks like this so that the end result reads like a sentence, e.g.

listbox with muliple selection should select all options using the select all method

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, noted.

@nielsr98
Copy link
Contributor Author

Feedback addressed. 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.

Looking good, just a few last minor comments

// TODO: improve to method to handle more complex combinations of elements and text
return this._elementRef.nativeElement.textContent;
/** Remove any child from the given element which can be identified as an icon. */
// TODO: make this a configurable function that can removed any desired type of node.
Copy link
Member

Choose a reason for hiding this comment

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

The TODO has to go inside the function above the for. If it's here, it breaks the connection between the function and its JsDoc block

@@ -89,19 +93,58 @@ export class CdkOption implements ListKeyManagerOption, Highlightable {
this._active = false;
Copy link
Member

Choose a reason for hiding this comment

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

Just noticed this incidentally while looking at something else: activate() checks _isInteractionDisabled, but deactivate doesn't

@@ -89,19 +93,58 @@ export class CdkOption implements ListKeyManagerOption, Highlightable {
this._active = false;
}

select() {
Copy link
Member

Choose a reason for hiding this comment

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

Add JsDoc for public API methods (here and below)

Comment on lines 121 to 125
const changeEvent = {
source: this,
isUserInput: isUserInput
};
this.selectionChange.emit(changeEvent);
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
const changeEvent = {
source: this,
isUserInput: isUserInput
};
this.selectionChange.emit(changeEvent);
this.selectionChange.emit({
source: this,
isUserInput: isUserInput
});

it's fine to inline the object literal

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay will change.

@nielsr98
Copy link
Contributor Author

Feedback addressed. Ready for another 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 Jul 15, 2020
@jelbourn jelbourn merged commit 7c49399 into angular:master Jul 16, 2020
ngwattcos pushed a commit to ngwattcos/components that referenced this pull request Jul 20, 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 Aug 16, 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.

4 participants