Cdk listbox: multipleselect support and active descendant - #19929
Conversation
|
|
||
| /** Applies focus to the option. */ | ||
| focus() { | ||
| console.log('focusing option'); |
| private _multiple: boolean = false; | ||
| private _useActiveDescendant: boolean = true; | ||
| private _activeOption: CdkOption; | ||
| private readonly _destroy = new Subject<void>(); |
There was a problem hiding this comment.
I think this is supposed to be a ReplaySubject(1)?
There was a problem hiding this comment.
I'm not sure what the difference is. I'll look into it.
There was a problem hiding this comment.
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
| this._listKeyManager = new ActiveDescendantKeyManager(this._options) | ||
| .withWrap().withVerticalOrientation().withTypeAhead(); | ||
|
|
||
| this._listKeyManager.change.subscribe(() => { |
There was a problem hiding this comment.
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.
Do you know if for/of loops should be used here? That's the Google internal style
There was a problem hiding this comment.
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'); |
| template: ` | ||
| <div cdkListbox | ||
| [useActiveDescendant]="isActiveDescendant" | ||
| > |
There was a problem hiding this comment.
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.
Yeah I think it should be on the previous line.
| @Component({ | ||
| template: ` | ||
| <div cdkListbox | ||
| [multiple]="isMultiselectable" |
There was a problem hiding this comment.
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.
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.
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.
jelbourn
left a comment
There was a problem hiding this comment.
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.
| 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.
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.
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.
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.
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.
I would probably move this code into its own function
| return this._multiple; | ||
| } | ||
| set multiple(value: boolean) { | ||
| if (this.multiple && !value) { |
There was a problem hiding this comment.
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.
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.
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.
| 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.
| 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.
This looks like it should be a private method?
| if (!this._isInteractionDisabled()) { | ||
| this.selected = !this.selected; | ||
| this.listbox._emitChangeEvent(this); | ||
| this.listbox._updateSelectionModel(this); |
There was a problem hiding this comment.
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. |
| } | ||
|
|
||
| private _emitSelectionChange(isUserInput = false) { | ||
| this.selectionChange.emit(new OptionSelectionChangeEvent(this, isUserInput)); |
There was a problem hiding this comment.
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.
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.
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.
| * 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.
| 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.
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); |
| private _initSelectionChange() { | ||
| this.optionSelectionChanges = defer(() => { |
There was a problem hiding this comment.
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.
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.
| if (previouslyActive) { | |
| previouslyActive.deactivate(); | |
| } | |
| previouslyActive?.deactivate(); |
| }); | ||
| }); | ||
|
|
||
| describe('multiselectable tests', () => { |
There was a problem hiding this comment.
| 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
|
Feedback addressed. Ready for review. |
jelbourn
left a comment
There was a problem hiding this comment.
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.
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.
Just noticed this incidentally while looking at something else: activate() checks _isInteractionDisabled, but deactivate doesn't
| this._active = false; | ||
| } | ||
|
|
||
| select() { |
There was a problem hiding this comment.
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.
| 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
|
Feedback addressed. Ready for another review. |
|
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.