Fix to bug 3409 (http://trac.openstreetmap.org/ticket/3409)
[potlatch2.git] / net / systemeD / controls / AutoComplete.as
1 /*
2         AutoComplete component
3         based on Adobe original but heavily bug-fixed and stripped down
4         http://www.adobe.com/cfusion/exchange/index.cfm?event=extensionDetail&extid=1047291
5         
6         Enhancements to do:
7         - up/down when field empty should show everything
8         - up (to 0) when dropdown displayed should cause it to reset to previous typed value
9         - down (past only item) when dropdown displayed should paste it
10         - shouldn't be able to leave empty fields, or those which already exist
11 */
12
13 package net.systemeD.controls {
14         import flash.events.Event;
15         import flash.events.FocusEvent;
16         import flash.events.KeyboardEvent;
17         import flash.ui.Keyboard;
18         
19         import mx.controls.ComboBox;
20         import mx.controls.DataGrid;
21         import mx.controls.listClasses.ListBase;
22         import mx.core.UIComponent;
23         import mx.events.ListEvent;
24
25         [Event(name="filterFunctionChange", type="flash.events.Event")]
26         [Event(name="typedTextChange", type="flash.events.Event")]
27
28         [Exclude(name="editable", kind="property")]
29
30         /**
31          *      The AutoComplete control is an enhanced 
32          *      TextInput control which pops up a list of suggestions 
33          *      based on characters entered by the user. These suggestions
34          *      are to be provided by setting the <code>dataProvider
35          *      </code> property of the control.
36          *      @mxml
37          *
38          *      <p>The <code>&lt;fc:AutoComplete&gt;</code> tag inherits all the tag attributes
39          *      of its superclass, and adds the following tag attributes:</p>
40          *
41          *      <pre>
42          *      &lt;fc:AutoComplete
43          *        <b>Properties</b>
44          *        keepLocalHistory="false"
45          *        typedText=""
46          *        filterFunction="<i>Internal filter function</i>"
47          *
48          *        <b>Events</b>
49          *        filterFunctionChange="<i>No default</i>"
50          *        typedTextChange="<i>No default</i>"
51          *      /&gt;
52          *      </pre>
53          *
54          * 
55          *  #includeExample ../../../../../../docs/com/adobe/flex/extras/controls/example/AutoCompleteCountriesData/AutoCompleteCountriesData.mxml
56          *
57          *      @see mx.controls.ComboBox
58          *
59          */
60          // Comment from Steve Bennett: this class is kind of nightmarish because of complicated event sequences, where 
61          // setting one property triggers an event which sets more properties... If someone can get their head around it,
62          // it would be nice to document what are the fundamental properties that should be set, and what sequences of events
63          // cascade out of that. textinput, text, selectedindex...
64         public class AutoComplete extends ComboBox 
65         {
66
67                 //--------------------------------------------------------------------------
68                 //      Constructor
69                 //--------------------------------------------------------------------------
70
71                 public function AutoComplete() {
72                         super();
73
74                         //Make ComboBox look like a normal text field
75                         editable = true;
76
77                         setStyle("arrowButtonWidth",0);
78                         setStyle("fontWeight","normal");
79                         setStyle("cornerRadius",0);
80                         setStyle("paddingLeft",0);
81                         setStyle("paddingRight",0);
82                         rowCount = 7;
83                 }
84                 
85                 //--------------------------------------------------------------------------
86                 //      Variables
87                 //--------------------------------------------------------------------------
88
89                 // Tracks cursor position. Because the text field has to be changed so often (to compensate for unwanted changes),
90                 // and because changing the text field moves the cursor, we need to keep track of where the cursor position should be
91                 // in order to restore it all the time. (Could be done more gracefully though.)
92                 private var cursorPosition:Number=0;
93                 // The previous state of selectedIndex - appears not to be used, though.
94                 private var prevIndex:Number = -1;
95                 /** Indicates that at the next UpdateDisplayList, the dropdown will be opened. */
96                 private var showDropdown:Boolean=false;
97                 /** Set by UpdateDisplayList, indicates that the dropdown is currently open. */
98                 private var showingDropdown:Boolean=false;
99                 private var tempCollection:Object;
100                 // Very confusing - why is there a dropdownClosed *and* a showingDropdown? They appear to be used by different functions
101                 // ...but why?
102                 private var dropdownClosed:Boolean=true;
103                 // produce spammy UI output
104                 private var dbg:Boolean = false;
105
106                 //--------------------------------------------------------------------------
107                 //      Overridden Properties
108                 //--------------------------------------------------------------------------
109
110                 /** Hardcoded to set value to true. */
111                 override public function set editable(value:Boolean):void {
112                         //This is done to prevent user from resetting the value to false
113                         super.editable = true;
114                 }
115                 /** This whole function is a temporary patch for the bug described inside. */
116                 override public function set dataProvider(value:Object):void {
117                         super.dataProvider = value;
118                         tempCollection = value;
119
120                         // Big bug in Flex 3.5:
121                         //  http://www.newtriks.com/?p=935
122                         //  http://forums.adobe.com/message/2952677
123                         //  https://bugs.adobe.com/jira/browse/SDK-25567
124                         //  https://bugs.adobe.com/jira/browse/SDK-25705
125                         //  http://stackoverflow.com/questions/3006291/adobe-flex-combobox-dataprovider
126                         // We can remove this workaround if we ever move to Flex 3.6 or Flex 4
127                         var newDropDown:ListBase = dropdown;
128                         if(newDropDown) {
129                                 validateSize(true);
130                                 newDropDown.dataProvider = super.dataProvider;
131
132                                 dropdown.addEventListener(ListEvent.ITEM_CLICK, itemClickHandler, false, 0, true);
133                         }
134                 }
135
136                 override public function set labelField(value:String):void {
137                         super.labelField = value;
138                         invalidateProperties();
139                         invalidateDisplayList();
140                 }
141
142
143                 //--------------------------------------------------------------------------
144                 //      Properties
145                 //--------------------------------------------------------------------------
146
147                 private var _typedText:String="";                       // text changed by user
148                 private var typedTextChanged:Boolean;
149
150                 [Bindable("typedTextChange")]
151                 [Inspectable(category="Data")]
152                 public function get typedText():String { return _typedText; }
153
154                 /** Records text that was actually typed by the user, as distinct from text automatically populated 
155                  * from the drop down list. This turns out to be pretty important as the TextInput field constantly
156                  * gets populated, unexpectedly.. */ 
157                 public function set typedText(input:String):void {
158                         
159                         if (dbg) trace("set typedText("+input+")");
160                         _typedText = input;
161                         typedTextChanged = true;
162                         
163                         invalidateProperties();
164                         invalidateDisplayList();
165                         dispatchEvent(new Event("typedTextChange"));
166                 }
167
168                 //--------------------------------------------------------------------------
169                 //      New event listener to restore item-click
170                 //--------------------------------------------------------------------------
171
172                 protected function itemClickHandler(event:ListEvent):void {
173                         typedTextChanged=false;
174                         textInput.text=itemToLabel(collection[event.rowIndex]);
175                         selectNextField();
176                 }
177
178                 /** Finds the next field to send focus to when user is done with this one. */
179                 protected function selectNextField():void {
180                         if (this.parent.parent is DataGrid) {
181                                 this.parent.parent.dispatchEvent(new FocusEvent("keyFocusChange",true,true,null,false,9));
182                         } else {
183                                 focusManager.getNextFocusManagerComponent(true).setFocus();
184                         }
185                 }
186
187                 //--------------------------------------------------------------------------
188                 //      Overridden methods
189                 //--------------------------------------------------------------------------
190
191                 override protected function commitProperties():void {
192                         super.commitProperties();
193
194                         if (dropdown) {
195                                 if (typedTextChanged) {
196                                         if (dbg) trace ("commitProperties: Move cursor from " + cursorPosition + " to " + textInput.selectionBeginIndex);  
197                                         cursorPosition = textInput.selectionBeginIndex;
198                                         updateDataProvider();
199
200                                         if( collection.length==0 || typedText=="" || typedText==null ) {
201                                                 // no suggestions, so no dropdown
202                                                 dropdownClosed=true;
203                                                 showDropdown=false;
204                                                 showingDropdown=false;
205                                                 selectedIndex=-1; //correct state when nothing in dropdown is selected
206                                         } else {
207                                                 // show dropdown
208                                                 showDropdown = true;
209                                                 selectedIndex = 0; // select first item in dropdown
210                                         }
211                                 }
212                         } else {
213                                 selectedIndex=-1 // There is no list of suggestions at all, so don't select anything in it
214                         }
215                 }
216                 
217                 override protected function updateDisplayList(unscaledWidth:Number, 
218                                                                   unscaledHeight:Number):void {
219
220                         super.updateDisplayList(unscaledWidth, unscaledHeight);
221                         if (dbg) trace ("updateDisplayList. textInput.text: " + textInput.text + 
222                         "; typedText: " + typedText + 
223                         "; selectedLabel: " + selectedLabel + 
224                         "; cursorPosition: " + cursorPosition);
225                         if (dbg) trace("   Showing/show/_dropdown: " + showingDropdown+ ", " + showDropdown +","+ dropdown + ". selectedIndex: " + selectedIndex + ". typedTextChanged: " + typedTextChanged);
226                         
227                         if(selectedIndex == -1 && typedTextChanged && textInput.text!=typedText) { 
228                                 // not in menu
229                                 if (dbg) { trace("   not in menu"); trace("- restoring to "+typedText); }
230                                 textInput.text = typedText;
231                             textInput.setSelection(cursorPosition, cursorPosition);
232                                 if (dbg) trace("   setSelection: textinput.text.length:" + textInput.text.length);
233                                 //textInput.setSelection(textInput.text.length, textInput.text.length);
234                                 if (dbg) trace ("   Option 1");
235                         } else if (dropdown && typedTextChanged && textInput.text!=typedText) {
236                                 // "in menu, but user has typed"
237                                 
238                                 if (dbg) { trace("   in menu, but user has typed"); trace("- restoring to "+typedText); }
239                                 textInput.text = typedText;
240                                 textInput.setSelection(cursorPosition, cursorPosition);
241                 if (dbg) trace ("   Option 2");                         
242                         } else if (showingDropdown && textInput.text==selectedLabel) {
243                                 // "force update if Flex has fucked up again"
244                                 
245                                 // this option happens when user types the last character of an autocomplete match
246                                 // the whole string also gets selected, which is a usability issue (makes it very hard 
247                                 // to keep typing, eg "motorway_link" 
248                                 if (dbg) trace("   should force update");
249                                 textInput.htmlText=selectedLabel;
250                                 textInput.validateNow();
251                 if (dbg) trace ("   Option 3");                         
252                         } else if (showingDropdown && textInput.text!=selectedLabel && !typedTextChanged) {
253                                 // in menu, user has navigated with cursor keys/mouse
254                                 if (dbg) trace("   in menu, user has navigated with cursor keys/mouse");
255                                 textInput.text = selectedLabel;
256                                 textInput.setSelection(0, textInput.text.length);
257                 if (dbg) trace ("   Option 4");                         
258                         } else if (textInput.text!="") {
259                                 // This is the most common situation when user is typing and it's not matching. But
260                                 // it's very complicated to predict when this one happens or when option 1. For example,
261                                 // sometimes you type 4 characters (Option 5) then suddenly the next character is option 1.
262                                 if (dbg) trace ("   Option 5, cursorPosition:" + cursorPosition);
263                                 textInput.setSelection(cursorPosition, cursorPosition);
264                                                 
265                         } else // occurs while keyboarding up and down menu, maybe also when exiting menu.
266                         if (dbg) trace ("   Option else");
267             if (dbg) trace ("Result. textInput.text: " + textInput.text + 
268             "; typedText: " + typedText + 
269             "; selectedLabel: " + selectedLabel + 
270             "; cursorPosition " + cursorPosition + "\n\n\n");
271
272                         if (showDropdown && !dropdown.visible) {
273                                 // controls the open duration of the dropdown
274                                 if (dbg) trace("   (Now open the drop down.)");
275                                 super.open();
276                                 showDropdown = false;
277                                 showingDropdown = true;
278                                 dropdownClosed = false;
279                         }
280                 }
281         
282                 override protected function keyDownHandler(event:KeyboardEvent):void {
283                         super.keyDownHandler(event);
284
285                         if (event.keyCode==Keyboard.UP || event.keyCode==Keyboard.DOWN) {
286                                 typedTextChanged=false;
287                         }
288
289                         if (event.keyCode==Keyboard.ESCAPE && showingDropdown) {
290                                 // ESCAPE cancels dropdown
291                                 textInput.text = typedText;
292                                 textInput.setSelection(textInput.text.length, textInput.text.length);
293                                 showingDropdown = false;
294                                 dropdownClosed=true;
295
296                         } else if (event.keyCode == Keyboard.ENTER) {
297                                 // ENTER pressed, so select the topmost item (if it exists)
298                                 // there is a usability issue here if the user is trying to type only part of an entry
299                                 // and there is only one matching item in the dropdown. (eg, they want to type 'foo' but
300                                 // there is 'footway'). It's not a killer because you can still escape by clicking somewhere
301                                 // else, but it's unsettling. Not too common, fortunately.
302                                 if (selectedIndex>-1) { textInput.text = selectedLabel; }
303                                 dropdownClosed=true;
304                                 
305                                 // and move on to the next field
306                                 event.stopImmediatePropagation();
307                                 selectNextField();
308
309                         } else if (event.ctrlKey && event.keyCode == Keyboard.UP) {
310                                 // Let the user manually shut the dropdown. fixme: cursor jumps
311                                 dropdownClosed=true;
312                         }
313                 
314                         prevIndex = selectedIndex;
315                 }
316         
317                 override public function getStyle(styleProp:String):* {
318                         if (styleProp != "openDuration") {
319                                 return super.getStyle(styleProp);
320                         } else {
321                                 if (dropdownClosed) return super.getStyle(styleProp);
322                                 else return 0;
323                         }
324                 }
325
326                 override protected function textInput_changeHandler(event:Event):void {
327                         if (dbg) trace("textInput_changeHandler, text was: " + text);
328                         super.textInput_changeHandler(event);
329                         if (dbg) trace("textInput_changeHandler, text is now: " + text + ". Cursor: " + cursorPosition);
330                         typedText = text;
331                         typedTextChanged = true;
332                 }
333
334                 override protected function measure():void {
335                         super.measure();
336                         measuredWidth = mx.core.UIComponent.DEFAULT_MEASURED_WIDTH;
337                 }
338
339                 override public function set selectedIndex(value:int):void {
340                         if (dbg) trace ("setSelectedIndex to " + value + ".");
341                         var prevtext:String=text;
342                         super.selectedIndex=value;
343                         if (dbg) trace ("   This made " + prevtext + " become " + text + " (now back again).");
344                         text=prevtext;
345                 }
346
347
348                 //----------------------------------
349                 //      filterFunction
350                 //----------------------------------
351                 /**
352                  *      A function that is used to select items that match the
353                  *      function's criteria. 
354                  *      A filterFunction is expected to have the following signature:
355                  *
356                  *      <pre>f(item:~~, text:String):Boolean</pre>
357                  *
358                  *      where the return value is <code>true</code> if the specified item
359                  *      should displayed as a suggestion. 
360                  *      Whenever there is a change in text in the AutoComplete control, this 
361                  *      filterFunction is run on each item in the <code>dataProvider</code>.
362                  *      
363                  *      <p>The default implementation for filterFunction works as follows:<br>
364                  *      If "AB" has been typed, it will display all the items matching 
365                  *      "AB~~" (ABaa, ABcc, abAc etc.).</p>
366                  *
367                  *      <p>An example usage of a customized filterFunction is when text typed
368                  *      is a regular expression and we want to display all the
369                  *      items which come in the set.</p>
370                  *
371                  *      @example
372                  *      <pre>
373                  *      public function myFilterFunction(item:~~, text:String):Boolean
374                  *      {
375                  *         public var regExp:RegExp = new RegExp(text,"");
376                  *         return regExp.test(item);
377                  *      }
378                  *      </pre>
379                  *
380                  */
381
382                 private var _filterFunction:Function = defaultFilterFunction;
383                 private var filterFunctionChanged:Boolean = true;
384
385                 [Bindable("filterFunctionChange")]
386                 [Inspectable(category="General")]
387
388                 public function get filterFunction():Function {
389                         return _filterFunction;
390                 }
391
392                 /** An empty filterFunction is allowed but not a null filterFunction*/
393                 public function set filterFunction(value:Function):void {
394                         
395                         if(value!=null) {
396                                 _filterFunction = value;
397                                 filterFunctionChanged = true;
398
399                                 invalidateProperties();
400                                 invalidateDisplayList();
401         
402                                 dispatchEvent(new Event("filterFunctionChange"));
403                         } else {
404                                 _filterFunction = defaultFilterFunction;
405                         }
406                 }
407                                 
408                 private function defaultFilterFunction(element:*, text:String):Boolean {
409                         var label:String = itemToLabel(element);
410                         return (label.toLowerCase().substring(0,text.length) == text.toLowerCase());
411                 }
412
413                 private function templateFilterFunction(element:*):Boolean {
414                         var flag:Boolean=false;
415                         if(filterFunction!=null)
416                                 flag=filterFunction(element,typedText);
417                         return flag;
418                 }
419
420                 /** Updates the dataProvider used for showing suggestions*/
421                 private function updateDataProvider():void {
422                         if (dbg) trace("updateDataProvider");
423                         dataProvider = tempCollection;
424                         collection.filterFunction = templateFilterFunction;
425                         collection.refresh();
426                 }
427         }       
428 }