-
Notifications
You must be signed in to change notification settings - Fork 6.8k
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
Conversation
@@ -89,19 +99,46 @@ export class CdkOption implements ListKeyManagerOption, Highlightable { | |||
this._active = false; | |||
} | |||
|
|||
/** Applies focus to the option. */ | |||
focus() { | |||
console.log('focusing option'); |
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.
Leftovers
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.
Yep, thanks.
private _multiple: boolean = false; | ||
private _useActiveDescendant: boolean = true; | ||
private _activeOption: CdkOption; | ||
private readonly _destroy = new Subject<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.
I think this is supposed to be a ReplaySubject(1)?
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'm not sure what the difference is. I'll look into it.
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 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(() => { |
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 one should also have takeUntil(this._destroy$)?
this._selectionModel.changed.pipe(takeUntil(this._destroy)) | ||
.subscribe((event: SelectionChange<CdkOption>) => { | ||
|
||
event.added.forEach((option: CdkOption) => { |
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.
Do you know if for/of loops should be used here? That's the Google internal style
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.
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'); |
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.
Leftovers
template: ` | ||
<div cdkListbox | ||
[useActiveDescendant]="isActiveDescendant" | ||
> |
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'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
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.
Yeah I think it should be on the previous line.
@Component({ | ||
template: ` | ||
<div cdkListbox | ||
[multiple]="isMultiselectable" |
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.
There's an extra space here?
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.
Actually I think the (selectionChange)
on the next line needs a space.
expect(option.getAttribute('aria-selected')).toBe('true'); | ||
} | ||
|
||
expect(fixture.componentInstance.changedOption).toBeDefined(); |
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 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.
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.
Since I am checking changedOption.id
directly after, do you think it is even necessary to check that changedOption
is defined?
…anged for each loop to for of loop.
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.
Got part of the way through before my next meeting, will do another pass in a bit
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); | ||
} | ||
} |
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.
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); | |
} |
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 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>(); |
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 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(); | ||
|
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 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.
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.
Sure that makes sense.
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); |
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 would probably move this code into its own function
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.
Yeah will do.
return this._multiple; | ||
} | ||
set multiple(value: boolean) { | ||
if (this.multiple && !value) { |
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 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) { |
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 could be a module-level function rather than a member on Option
return this._useActiveDescendant; | ||
} | ||
set useActiveDescendant(shouldUseActiveDescendant: boolean) { | ||
this._useActiveDescendant = shouldUseActiveDescendant; |
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.
coerceBooleanProperty
here
private _multiple: boolean = false; | ||
private _useActiveDescendant: boolean = true; | ||
private _activeOption: CdkOption; | ||
private readonly _destroy = new Subject<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.
private readonly _destroy = new Subject<void>(); | |
private readonly _destroyed = new Subject<void>(); |
if (this._useActiveDescendant | ||
&& this._listKeyManager && this._listKeyManager.activeItem) { | ||
return this._listKeyManager.activeItem.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.
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() { |
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 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); |
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 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
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)); |
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.
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.
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.
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.
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 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.
* 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. |
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.
* 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) { |
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.
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).
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.
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); |
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.
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.
Oops.
private _initSelectionChange() { | ||
this.optionSelectionChanges = defer(() => { |
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.
Does this need to be a function that gets called, or could optionSelectionChanges
be set just as part of its definition?
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.
Yeah it can be part of the definition. I can also make it readonly then. I'll change.
if (previouslyActive) { | ||
previouslyActive.deactivate(); | ||
} |
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 (previouslyActive) { | |
previouslyActive.deactivate(); | |
} | |
previouslyActive?.deactivate(); |
}); | ||
}); | ||
|
||
describe('multiselectable tests', () => { |
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.
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
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 see, noted.
Feedback addressed. Ready for review. |
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.
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. |
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 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; |
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.
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() { |
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.
Add JsDoc for public API methods (here and below)
const changeEvent = { | ||
source: this, | ||
isUserInput: isUserInput | ||
}; | ||
this.selectionChange.emit(changeEvent); |
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.
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
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.
Okay will change.
Feedback addressed. Ready for another review. |
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
This issue has been automatically locked due to inactivity. Read more about our automatic conversation locking policy. This action has been performed automatically by a bot. |
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.