angular / components

Component infrastructure and Material Design components for Angular
https://material.angular.io
MIT License
24.35k stars 6.74k forks source link

[Autocomplete] Restrict selection to given set of items #3334

Closed julianobrasil closed 1 year ago

julianobrasil commented 7 years ago

Bug, feature request, or proposal:

Request

What is the expected behavior?

md-autocomplete could have an option to force selection. I think it's not in the design doc, but near 50% of the autocompletes I've used must had to force selection.

EDITED: When the requested "force selection" option is set to true, it could clear the input (and any bound property) if the user types something in and moves the focus to other component without selecting one of the options shown on the opened panel (if there are no sugestions, the input is also cleared on the blur event).

What is the current behavior?

EDITED: The feature is not achievable direclty from the component. In one single project I'm working on, I have about 15 md-autompletes, and 11 of them must force selection. Currently I got this feature by two steps:

1. checking (in the intput's valueChanges observable) wether the value is an object - that I save in a private result property - or a regular string - that is ignored, by clearing up the same private result property (basically the input value is an object just when I select one option from the opened panel otherwise it is just a regular string that must be ignored)

2. in the blur event I verify wether the private result property is cleared or has a value (if it's cleared, I also clear the input).

Another way to do that is comparing what was typed to what came from the async server search - but I'm not sure if either of these aproaches is the best solution not wether it's suitable to the case of a search made directly in an in-memory array instead of bringing results fom a remote server. There are too many confusing workarounds to make it do what you want. I'm worried about the future, when I eventualy have to change anything in this code - it will be very time-consuming to remember all of this. There would be much less pain if, in a year from now I could just look at the component and see something like forceSelection="true".

crisbeto commented 7 years ago

Could you give an example of how it would work? The menu wouldn't close unless you select something?

julianobrasil commented 7 years ago

@crisbeto, If the user began to type and left the input without selecting one of the options shown in the opened panel, it would clear the input (and any bound property).

crisbeto commented 7 years ago

Thanks, that makes a bit more sense.

rosslavery commented 7 years ago

Basically a hybrid between a selectbox and an autocomplete. Can be thought of as:

fxck commented 7 years ago

I've hacked around this by checking whether the model value is an object (given that my items are actually objects) on blur, if it's a string, it means autocomplete was blurred without selecting an option (the value will be a string). But it's nowhere near ideal, it requires setTimeouts, as the value immediately after blur will still be a string even if you actually click on an item, as it needs some time to propagate.

julianobrasil commented 7 years ago

@fxck I had forgotten to mention the setTimouts. I had to use them too in the blur event handler in some cases (where there was a group of dependent mdSelects that should keep their values if they were related to the new option selected from the autocomplete's list, but should also be cleared if the user didn't choose an option or if they were not related to the chosen option selected).

badre429 commented 7 years ago

i agree with @rosslavery in real world app md-select has avg > 50 elements therfore its more productive to use autocomplete that only allows values from the suggestion box.

it heard breaking to see your users scrool al the way down to select element

fxck commented 7 years ago

@badre429 @rosslavery there's a different feature for that https://github.com/angular/material2/pull/3211

here specifically you can see select filtering with async results https://github.com/fxck/material2/commit/f0dd2ec4654307f4ef4eedebb57961c06f83ee56

badre429 commented 7 years ago

@fxck its a just a pull request for me md-select with md-select-header to enable search plus clean buttun [x] it will be perfect for my apps

gedclack commented 7 years ago

Excuse me, how do you guys make a selection (from hundreds of options) without the discussed md-select-header ? by using autocomplete? <-- but this is not built for doing that, right?

julianobrasil commented 7 years ago

@gedclack, this is an example of a email search input. As the user types an email it goes to the database, grab the suggestions and shows them in the popup panel. If the user leaves the component without selecting one of them, it clears out the typed characters.

style.css (top level file - nothing to do with template style file), not necessary since 2.0.0-beta.3 cesium-cephalopod

.mat-autocomplete-panel {
    max-width: none !important;
}

Template:

<md-input-container class="full-width">
   <!-- this the actual input control -->
   <input mdInput [formControl]="acpEmailControl" [mdAutocomplete]="acpEmail" 
      type="text" placeholder="email" autocomplete="off"
      (blur)="checkEmailControl()" autofocus required>
   <!--this produces an animation while searching for the typed characters-->
   <span mdPrefix>
      <i [style.visibility]="showEmailSearchSpinner" class="fa fa-refresh fa-spin" 
        style="font-size:14px;color:black;margin-right:5px"></i>
   </span>
</md-input-container>

<md-autocomplete #acpEmail="mdAutocomplete" [displayWith]="displayEmailFn">
   <md-option *ngFor="let user of usersFromDatabase" [value]="user" 
     [style.font-size]="'0.7rem'">
      <div>
         {{ user?.userName }}<div style="float: right">{{user?.email}}</div>
      </div>
   </md-option>
</md-autocomplete>

Typescript code:

public acpEmailControl: FormControl = new FormControl();
private emailSearchSubscription: Subscription;
public showEmailSearchSpinner = 'hidden';
public usersFromDatabase: User[] = [];
public chosenUser: User;

ngOnInit() {
   // unsubscribe in ngOnDestroy
   this.emailSearchSubscription = this.acpEmailControl.valueChanges
      .startWith(null)
      .map((user: User | any) => {
        if (user && (typeof user === 'object')) {
          this.chosenUser = user;
        } else {
          this.chosenUser = new User();
        }

        return user && typeof user === 'object' ? user.userName : user;
      })
      .debounceTime(300)
      .switchMap<string, User[]>((typedChars: string) => {
        if (typedChars !== null && typedChars.length > 1) {
          this.showEmailSearchSpinner = 'visible';
          //  Observable that goes to the database and grabs the
         //  users with email or name based on typedChars
          return this.userService.findLikeNameOrEmail(typedChars);
        } else {
          this.showEmailSearchSpinner = 'hidden';
          return Observable.of<User[]>([]);
        }
      })
      .subscribe((value: User[]) => {
        this.showEmailSearchSpinner = 'hidden';
        if (value.length > 0) {
          this.usersFromDatabase = value;
        } else {
          this.usersFromDatabase = [];
        }
      },
      err => console.log('error'));
}

// clears out the input if the user left it (blur event) without
// actually choose one of the options presented in the list
// The setTimeout is because the blur event propagates
// faster then input changes detection 
// (see @fxck's comment above)
checkEmailControl() {
  setTimeout(() => {
    if (this.chosenUser.email === '') {
      this.acpEmailControl.setValue(null);
    } 
  }, 300);
}

displayEmailFn(user: User) {
  return (user && user.email && (user.email !== '')) ? user.email : '';
}
gedclack commented 7 years ago

@julianobrasil thanks for showing me the codes :+1: I will try it now in my project. to reduce data transfer, is it a good idea if I load all the options in ngOnInit() and put it in a variable, then just filter that array with every valueChanges? which is better, to use FormControl and subscribe valueChanges like you did above, or just put (ngModelChange)="InputChanged()" in the template?

Thanks in advance, I am new to Angular and Angular Material.

julianobrasil commented 7 years ago

@gedclack, yes, you can choose the approach of saving the user's bandwith by putting all in memory (as it is done in the material.angular.io). It depends on what type of machines you expect your clients to use and how much information is available to be placed and how often you expect the end users to make use of the feature. In my environment the most common type are PC's with low memory and Microsoft office applications running with lot's of small documents. They usually have a bad UI experience dispite of all efforts to make it better. I thought saving some memory was a good path to follow. By doing it I also have the benefit of delegating to the data server the work of filtering the array for me. And of course, data transfer rates is not a significant problem for the majority of my end users.

You can change the code to use ngModelChange, but if you're going to contact the server over a network as I am, you'd have to add some extra code to do equivalent things as .debounce(300), which is available right out of the box for the valueChanges observable. Not mentioning you'll end up using another observable inside ngModelChange's function to get to the server side (as I do in the switchMap in the example code) - not sure if you're trying to run away from observables, but if so, forget it and get used to them (personally I think it's a hard thing to do in the beginning, but like anything else, it'll get easier with the practice, and they're one of the most important veins in Angular's heart).

Both of your questions may be good for larger discussions. Try asking them on stackoverflow.com to get more help (I've already heard a lot of "here is not the right place for this kind of question" in many posts. Let's avoid get lectured by moderators).

gedclack commented 7 years ago

@julianobrasil yep, better not to do long discussions here :) , your approach makes sense for me, but I choose to use ngModelChange and filter the array of options I put in a variable as the page load for the first time because my end users will access this Web App via Android Devices from remote locations with weak signal coverage, so I need it to send or receive data as small as possible everytime they submit something without the need to reload the entire page. Nice chat :+1:

willshowell commented 7 years ago

Here is a solution that doesn't require setTimeout:

this.trigger.panelClosingActions
  .subscribe(e => {
    if (!(e && e.source)) {
      this.stateCtrl.setValue(null)
      this.trigger.closePanel()
    }
  })

https://plnkr.co/edit/VWcGei7HxHYnncpzyYfW?p=preview

~EDIT: note that this does not include escaping out of the autocomplete, tracking https://github.com/angular/material2/issues/6209~

julianobrasil commented 7 years ago

Much... much better... setTimeout always smelled like a fragile workaround to me.

kleber-swf commented 7 years ago

I've created a Validator to give an error if no option was selected. I know this is not the solution for this feature request, but it can be used as an alternative.

kleber-swf commented 7 years ago

But I have to admit, the @willshowell solution is much better 👍

willshowell commented 7 years ago

@jelbourn what are you thoughts api-wise for something like this?

julianobrasil commented 7 years ago

@willshowell, is there a difference between forceSelection and requireMatch? Or is it just a matter of names?

Edited: BTW, I suggested forceSelection in this feature request just because it was the one I used when I was working with jquery ui... or JSF Primefaces - can't remember exactly and also not sure if it was part of their api. Anyway, it was not an important reason. IMO, both of the names seems to describe well what it is supposed to do. As English isn't my native language, any of them are fine to me (at least both of them sound equally good in their "Portuguese translation").

willshowell commented 7 years ago

@julianobrasil haha yeah just another name. I was also looking around at other implementations and saw md-require-match is used in Material 1 and in Covalent chips, so just a suggestion 😄 I have no preference either way... I would be content with anything better than the current workaround.

julianobrasil commented 7 years ago

In this case I'd go with requireMatch to keep it compatible with Material 1.

jelbourn commented 7 years ago

I fired off a query to our Google a11y team to see if this can be done in an accessible way, since the W3C description for combobox is ambiguous as to whether arbitrary text entry is required or allowed.

willshowell commented 7 years ago

@jelbourn any feedback from the a11y team?

jelbourn commented 7 years ago

Unfortunately not yet

thw0rted commented 7 years ago

Thanks @willshowell for the example -- based on this, I was able to check this.trigger.activeOption to force selection when leaving focus:

this.trigger.panelClosingActions.subscribe(e => {
  if (this.trigger.activeOption) {
    this.stateCtrl.setValue(this.trigger.activeOption);
    this.trigger.closePanel();
  }
}

Now I just need to focus the first suggestion after every list change and I'll have some reasonable UX for the interface. (I have the luxury of neglecting a11y to an extent because my project is built around displaying maps.)

julianobrasil commented 7 years ago

It seems something has changed in beta.12, it's not working anymore in the same way it did in beta.11: https://plnkr.co/edit/UsxFJSREpyJJCuOj0kHT?p=preview

When you leave the input with a TAB, even if you choose an option from the list, panelClosingActions is emiting a void and trigger.activeOption is also undefined at that moment.

Note: fixed in #8533

julianobrasil commented 6 years ago

@crisbeto explained to me (https://github.com/angular/material2/issues/8093#issuecomment-340196775) what MatAutocompleteTrigger.activeOption is supposed to do and it's working as expected:

https://stackblitz.com/edit/autocomplete-force-selection

willshowell commented 6 years ago

I don't really undertand the w3c documentation/drafting process, but I see here (in what seems to be deleted archive section?) that the term is "editable" vs "non-editable" auto-complete.

A text box and an associated drop-down list of choices where the choices offered are filtered based on the information typed into the box. Typically, an icon associated with the text box triggers the display of the drop-down list of choices. An editable auto-complete accepts text entry of choices that are not in the list. An example of an editable auto-complete is the URL field in the browsers. ...

  • Moving focus out of an auto complete field that does not contain a valid entry should either invoke an error or if a default value was initially assigned, reset the value to the default value.

There seem to be other considerations that would signal that a selection from the autocomplete dropdown is required:

In a non-editable auto-complete, any letters that do not result in a match from the list are ignored. The drop down list of choices remains static until the user presses:

  • Escape to clear the text field
  • Backspace to remove some of the letters previously typed
  • an additional letter that results in a valid list of choices.
julianobrasil commented 6 years ago

One step forward: #8533

aceleghin commented 6 years ago

Using this way I have an error: Cannot read property 'panelClosingActions' of undefined The code is inside the ngAfterViewInit(). And of course I already have @ViewChild(MatAutocompleteTrigger) trigger: MatAutocompleteTrigger;

julianobrasil commented 6 years ago

@lippomano, are you sure you're not trying to access it too soon? Like in the OnInit instead of in the AfterViewInit handler; or right after the component become visible as a result of a *ngIf switching to true.

thw0rted commented 6 years ago

I had this problem until I took it out from under an element that had an *ngIf -- trying to get a ViewChild on an element that's only there sometimes is more trouble than it's worth. Better to convert the ancestor element to a [hidden] or similar.

julianobrasil commented 6 years ago

Alternatively you can use some setTimeouts. It really depends on how your app behaves.

julianobrasil commented 6 years ago

It seems @willshowell's solution is working fine since 5.0.2: https://stackblitz.com/edit/autocomplete-force-selection-tests

Thanks, @crisbeto.

rafaelss95 commented 6 years ago

@julianobrasil It seems like your demo is broken because you forgot to import rxjs operators.

Here's a working version.

julianobrasil commented 6 years ago

Thanks, @rafaelss95. I accidentally removed some rxjs imports during the final clean up in the demo code.

noobgramming commented 6 years ago

My proposal was removed as dupe, but I feel using a validator would make the feature more congruent with Angular's built-in form validation since we're essentially marking the field invalid by clearing it on blur. @kleber-swf proposed using a validator and I went the same route in the commercial codebase I'm working with. It's simple and works great. IMO, clearing the field is not as user friendly and I argue that you could 'validate' any text field by clearing invalid input on blur but it's not always going to be the best user experience. =)

Also, one of the requirements I'm handling in our codebase is that the valid options can change over time. Clearing what the user typed in will not work in this use-case since it's important to see what was there before. Since the Autocomplete attaches to a regular form input field I would prefer it act like one, where I can set arbitrary values but they won't necessarily be valid. To me this is a difference of opinion primarily over whether an autocomplete should act more like a select or a text input.

Another argument for a validator is that people who do data-entry will get used to typing in the whole value and quickly tabbing to the next field. Losing the whole field if you have a typo is annoying compared to shift-tab to correct a mistake.

I'll appreciate the feature however it gets realized, but If the Material team decides to go the validator route @kleber-swf has an implementation and I have one that I can probably get cleared as an OSS contribution.

rosslavery commented 6 years ago

If the intention is to limit a user's input to one of the suggestions, then why even allow a user to type in arbitrary text and have it commited to the model, only to have it be validated, and then show them an error message.

I genuinely think the better UX leans towards what I mentioned in my comment way back in February.

Which is, a "select with a search feature". There are countless libraries on the internet that provide an acceptable level of UX while accomplishing what people have been wanting now for quite some time. See: select2, chosen, selectize, etc, etc.

I respectfully think that there's a bit too much navel-gazing / bikeshedding regarding this issue. Back and forth with the a11y team, discussing not-yet-implemted w3c standards. Throughout the development material2, many other components and features have been implemented in a WIP state and improved upon later.

Perhaps it will be resolved by this PR allowing for a custom mat-select header.

Edit: Also, the angular-dart team seems to have implemented a similar component: https://dart-lang.github.io/angular_components_example/#AutoSuggestInput

Maybe consult with them about some of the accessibility issues that are being debated? From their demo, you can see that their search filter limits the number of available options, while only committing the selected option to the model.

julianobrasil commented 6 years ago

IMO both select with a search feature and an autocomplete with a force selection are very welcome.

Sometimes you want to keep your options updated with a server database or simply don't want to load all of your possible options at once (to filter them later when the user types in). I this cases, the autocomplete with a force selection input would be more suitable. Otherwise the select filter would be perfect.

thw0rted commented 6 years ago

Juliano hits the nail on the head -- "searchable select" is not helpful for cases where your options number in the hundreds to thousands. If nothing else, just passing a few thousand <option> tags to the HTML parser can result in multiple-second page loads, especially on older / slower clients (and I speak from experience). The use case for "one of a list of thousands of items, which must already exist on the backend" is definitely not uncommon.

rosslavery commented 6 years ago

I don't think searchable select necessarily implies that all <option> tags be in the DOM at once. Most of the libraries I mentioned above (select2, chosen, etc), while not perfect, have the ability to behave like a select, while still loading options in async from an endpoint.

To me the difference between autocomplete and searchable select is that autocomplete offers a list of suggestions to complete your input for you, while still allowing arbitrary text to be sent to the model -- both during typing, and after selecting an item from the suggestion list.

A searchable select shows a list of suggestions based on the user's input, but will not commit the search query itself to the model -- it only saves to the model if the user selects from one of the options.

kylecordes commented 6 years ago

@rosslavery @thw0rted @julianobrasil I have published some code (and the running demo) which achieves some of what is discussed here. It is a searchable select, but it is designed to be able to work with server-side data sources. It can be used to search a list vastly larger than you would ever load in the DOM. It is used for exactly these kind of use cases. The documentation and demo are pretty rough, but the code behind it as well proven.

It is more of a searchable select, and less of an autocomplete, and that it only allows values which the functions provided by the developer (presumably to talk to a backend server) approve of.

https://github.com/OasisDigital/angular-material-obs-autocomplete

demo:

https://oasisdigital.github.io/angular-material-obs-autocomplete/

kylecordes commented 6 years ago

Update to the above - I had a large improvement to that thing that I had not yet pushed to Github. I had updated the demo but not the code. Ooops. It is now pushed. If you're interested in a server-side capable auto complete, take another look.

noobgramming commented 6 years ago

It seems we have several different approaches and implementation for each. I would prefer a validator but I'm fine with anything, can someone from ng team please make the call so we can open a PR for the relevant implementation? We're getting pretty close to this issues' first birthday. 🍰

aceleghin commented 6 years ago

@julianobrasil , @rafaelss95 I am using your solution but I am in angular 4.4.x and I have a problem, when I select an option suggested in the autocomplete and then click tab to go in the next field I see the autocomplete field empty ( It triggers the panelClosingActions at Tab)
this.subscription = this.trigger.panelClosingActions .subscribe(e => { if (!e || !e.source) { this.stateCtrl.setValue( null); } }, Same problem as here plunker if you select field and click Tab

julianobrasil commented 6 years ago

@lippomano, unfortunatelly, there was a bug that causes the problem you're describing and was fixed in Material 5.0.2 (the fix was finalized in 5.0.3). And Material 5 depends on Angular 5. You'll have to upgrade everything or step away from panelClosingActions for this purpose. If the upgrade strategy is not an option, you'll have to work with some setTimouts to get the force selection feature (the code becomes a little messy without panelClosingActions - I personally don't like it).

vlio20 commented 6 years ago

I have created a small directive, inspired by @julianobrasil stackblitz demo and solution using the trigger panelClose event.

template:

<input matInput
             autoClose
             [control]="ctrl"
             [autoCompleteRef]="auto"
             placeholder="Blarg..."
             [matAutocomplete]="auto"
             [formControl]="ctrl">
<mat-autocomplete #auto="matAutocomplete">
            <mat-option *ngFor="let item of items" [value]="item">
              <span>{{item}}</span>
            </mat-option>
 </mat-autocomplete>

autoClose directive:

@Directive({
  selector: '[autoClose]'
})
export class AutoCloseDirective implements AfterContentInit {

  @Input() control: FormControl;
  @Input() autoCompleteRef: MatAutocomplete;

  subscription: Subscription;

  constructor(@Host() @Self() private readonly autoCompleteTrigger: MatAutocompleteTrigger) {
  }

  ngAfterContentInit() {
    if (this.control === undefined) {
      throw Error('inputCtrl @Input should be provided ')
    }

    if (this.autoCompleteRef === undefined) {
      throw Error('valueCtrl @Input should be provided ')
    }

    setTimeout(() => {
      this.subscribeToClosingActions();
      this.handelSelection();
    }, 0);
  }

  private subscribeToClosingActions(): void {
    if (this.subscription && !this.subscription.closed) {
      this.subscription.unsubscribe();
    }

    this.subscription = this.autoCompleteTrigger.panelClosingActions
      .subscribe((e) => {
          if (!e || !e.source) {
            this.control.setValue(null);
          }
        },
        err => this.subscribeToClosingActions(),
        () => this.subscribeToClosingActions()
      );
  }

  private handelSelection() {
    this.autoCompleteRef.optionSelected.subscribe((e: MatAutocompleteSelectedEvent) => {
      this.control.setValue(e.option.value);
    });
  }
}

note: if someone has an idea how to loose the setTimeout please let me know

mustafarian commented 6 years ago

@vlio20 Thanks for the directive idea/code. It's the least intrusive approach IMO.

You can simplify it a little by injecting NgControl into your directive and leaving it out or the inputs. Also I used matAutocomplete which is already defined in the input instead of adding autoCompleteRef and it worked just the same.

This is how it looked after I made these changes.

<input matInput
             enforcedInputs
             placeholder="Blarg..."
             [matAutocomplete]="auto"
             [formControl]="ctrl">
  <mat-autocomplete #auto="matAutocomplete">
            <mat-option *ngFor="let item of items" [value]="item">
              <span>{{item}}</span>
            </mat-option>
 </mat-autocomplete>

and the relevant bits from the directive

export class EnforcedInputsDirective implements AfterContentInit {

    @Input()
    matAutocomplete: MatAutocomplete;

    constructor( @Host() @Self() private readonly autoCompleteTrigger: MatAutocompleteTrigger, private control: NgControl) {
    }

    private subscribeToClosingActions(): void {
        if (this.subscription && !this.subscription.closed) {
            this.subscription.unsubscribe();
        }

        this.subscription = this.autoCompleteTrigger.panelClosingActions
            .subscribe((e) => {
               if (!e || !e.source) {
                    const selected = this.matAutocomplete.options
                        .map(option => option.value)
                        .find(option => option === this.formControl.value);

                    if (selected == null) {
                        this.formControl.setValue(null);
                    }
                }
            },
            err => this.subscribeToClosingActions(),
            () => this.subscribeToClosingActions());
    }
.....
}

Edit: the original implementation has an issue whereby if the user focuses an input field that has a valid value and tabs away from the field (the field looses focus) the directive will clear the field value. We want to persist the field value if it's valid and only clear it if it's not in the list. To do that I added a check in the directive to see if the current value is still in the list of options, otherwise clear it.

vlio20 commented 6 years ago

@mustafarian didn't know about the NgControl options. Indeed it is better.