Created
April 12, 2014 20:16
-
-
Save nikgraf/10554605 to your computer and use it in GitHub Desktop.
Web UI Autocomplete in Blossom
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:async'; | |
import 'dart:html'; | |
import 'package:web_ui/web_ui.dart'; | |
import 'package:escape_handler/escape_handler.dart'; | |
class State { | |
static const ACTIVE = const State._(0); | |
static const INACTIVE = const State._(1); | |
final int value; | |
const State._(this.value); | |
} | |
class AutocompleteEntry { | |
final String id; | |
final String searchableText; | |
final Element _element; | |
AutocompleteEntry(this.id, this.searchableText, this._element) { | |
// remove the data-id and data-text since we don't need them in the html | |
_element.dataset.remove('text'); | |
_element.dataset.remove('id'); | |
} | |
get sanitizedHtml { | |
var validator = new NodeValidatorBuilder()..allowHtml5(); | |
var documentFragment = document.body.createFragment(_element.outerHtml, validator: validator); | |
return documentFragment; | |
} | |
} | |
@observable | |
class AutocompleteComponent extends WebComponent { | |
static const EventStreamProvider<CustomEvent> selectEvent = const EventStreamProvider<CustomEvent>('select'); | |
String maxHeight = "200px"; | |
String width = "200px"; | |
String addText = "Add …"; | |
String placeholder = ""; | |
String fontSize = "14px"; | |
StreamSubscription _keyUp; | |
String _elementTimestamp = "0"; | |
EscapeHandler _escapeHandler = new EscapeHandler(); | |
@observable String _filterQuery = ""; | |
List _entries = toObservable([]); | |
List _filteredEntries = toObservable([]); | |
AutocompleteEntry _activeEntry = null; | |
State _state = State.INACTIVE; | |
Timer updateDataSourceTimer; | |
void inserted() { | |
_keyUp = document.onKeyUp.listen(null); | |
_keyUp.onData(_keyUpHandler); | |
_entries.clear(); | |
Element dataSource = getShadowRoot('b-autocomplete').querySelector('.data-source'); | |
if (dataSource != null) { | |
for (Element element in dataSource.children) { | |
bool containsText = element.dataset.containsKey('text'); | |
bool containsID = element.dataset.containsKey('id'); | |
if (containsText && containsID) { | |
_entries.add(new AutocompleteEntry(element.dataset['id'], | |
element.dataset['text'], element)); | |
} else if (dataSource.children.first is TemplateElement) { | |
} else { | |
print("Missing data-text or data-id from an source entry."); | |
} | |
} | |
} else { | |
print("Missing a data source like <div class=\"data-source\"><div data-text=\"dart\">Dart</div></div>"); | |
} | |
this._setCssStyles(); | |
} | |
void _setCssStyles() { | |
Element mainArea = getShadowRoot('b-autocomplete').query('.q-autocomplete-main-area'); | |
mainArea.style.width = width; | |
} | |
void activate(Event event) { | |
event.preventDefault(); | |
if (_state != State.ACTIVE) { | |
_state = State.ACTIVE; | |
Element field = getShadowRoot('b-autocomplete').querySelector('.q-autocomplete-activation-area'); | |
field.style.display = 'none'; | |
} | |
} | |
void clear() { | |
_filterQuery = ""; | |
_filteredEntries.clear(); | |
} | |
void reset() { | |
_filterQuery = ""; | |
_updateFilteredEntries(); | |
} | |
void removeSourceEntry(String dataID) { | |
_entries.removeWhere((AutocompleteEntry entry) => entry.id == dataID); | |
_updateFilteredEntries(); | |
} | |
String focusOnInput() { | |
Element field = getShadowRoot('b-autocomplete').querySelector('.q-autocomplete-form-input'); | |
field.focus(); | |
return ''; | |
} | |
Stream<CustomEvent> get onSelect => selectEvent.forTarget(this); | |
void _focused() { | |
_elementTimestamp = new DateTime.now().millisecondsSinceEpoch.toString(); | |
var deactivateFuture = _escapeHandler.addWidget(int.parse(_elementTimestamp)); | |
deactivateFuture.then((_) { | |
_blurred(); | |
}); | |
_updateFilteredEntries(); | |
} | |
void _blurred() { | |
_escapeHandler.removeWidget(int.parse(_elementTimestamp)); | |
// the element is deactive and we give it 0 as timestamp to make sure | |
// you can't find it by getting the max of all elements with the data attribute | |
_elementTimestamp = "0"; | |
clear(); | |
_state = State.INACTIVE; | |
Element field = getShadowRoot('b-autocomplete').querySelector('.q-autocomplete-activation-area'); | |
field.style.display = 'block'; | |
} | |
void _setToActiveEntry(AutocompleteEntry entry) { | |
_activeEntry = entry; | |
} | |
void _select(Event event) { | |
event.preventDefault(); | |
var detail = {'id': _activeEntry.id, 'text': _activeEntry.searchableText}; | |
dispatchEvent(new CustomEvent("select", detail: detail)); | |
reset(); | |
focusOnInput(); | |
} | |
void _updateFilteredEntries() { | |
var sanitizedQuery = _filterQuery.trim().toLowerCase(); | |
var filteredEntries = []; | |
if (sanitizedQuery == "") { | |
filteredEntries = new List.from(_entries); | |
} else { | |
filteredEntries = _entries.where((AutocompleteEntry entry) { | |
return entry.searchableText.trim().toLowerCase().contains(sanitizedQuery); | |
}); | |
} | |
_filteredEntries.clear(); | |
_filteredEntries.addAll(filteredEntries); | |
if (_filteredEntries.isNotEmpty) { | |
_activeEntry = _filteredEntries.first; | |
} | |
} | |
void _keyUpHandler(KeyboardEvent event) { | |
Element input = getShadowRoot('b-autocomplete').querySelector('.q-autocomplete-form-input'); | |
if (document.activeElement == input) { | |
switch (new KeyEvent.wrap(event).keyCode) { | |
case KeyCode.UP: | |
_moveUp(); | |
break; | |
case KeyCode.DOWN: | |
_moveDown(); | |
break; | |
} | |
} | |
} | |
_moveUp() { | |
var tmp = _filteredEntries.reversed.skipWhile((entry) => entry != _activeEntry); | |
if (tmp.length >= 2) { | |
_activeEntry = tmp.elementAt(1); | |
} | |
} | |
_moveDown() { | |
var tmp = _filteredEntries.skipWhile((entry) => entry != _activeEntry); | |
if (tmp.length >= 2) { | |
_activeEntry = tmp.elementAt(1); | |
} | |
} | |
void removed() { | |
if (this._keyUp != null) { try { this._keyUp.cancel(); } on StateError {}; } | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
</head> | |
<body> | |
<element name="b-autocomplete" constructor="AutocompleteComponent" extends="div"> | |
<template> | |
<style> | |
.b-autocomplete-data-source-wrapper { | |
display: none; | |
} | |
.b-autocomplete-prefix-area-wrapper { | |
float: left; | |
display: block; | |
} | |
.b-autocomplete-main-area { | |
display: block; | |
float: left; | |
position: relative; | |
} | |
.b-autocomplete-active { | |
background: #ccc; | |
cursor: pointer; | |
} | |
.b-autocomplete-results { | |
display: block; | |
overflow-y: scroll; | |
background: #fff; | |
position: absolute; | |
left: 0; | |
right: 0; | |
top: 24px; | |
z-index: 10000; | |
border: 1px solid #b2b2b2; | |
margin-top: 0px !important; | |
border-radius: 3px; | |
border-top-right-radius: 0; | |
border-top-left-radius: 0; | |
-webkit-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.148438); | |
-moz-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.148438); | |
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.148438); | |
} | |
.b-autocomplete-results-li { | |
background-color: #fff; | |
margin: 0; | |
width: auto; | |
border-bottom: 1px solid #eee; | |
overflow: hidden; | |
cursor: pointer; | |
list-style: none; | |
white-space: nowrap; | |
} | |
.b-autocomplete-results-li:last-child { | |
border-bottom: none; | |
} | |
.b-autocomplete-results-li.b-autocomplete-active { | |
background-color: #f2f2f2; | |
} | |
.no-touch .b-autocomplete-results-li:hover { | |
background-color: #f2f2f2; | |
} | |
.b-autocomplete-activation-area { | |
display: block; | |
overflow: hidden; | |
margin-bottom: 2px; | |
} | |
.b-autocomplete-form { | |
float: left !important; | |
display: none; | |
} | |
.b-autocomplete-form-input { | |
/* need to overwrite width from bootstrap input width */ | |
width: 100% !important; | |
height: 20px; | |
font-size: 14px; | |
line-height: normal !important; | |
color: #666; | |
margin: 0 !important; | |
margin-top: 2px !important; | |
padding: 0 !important; | |
-webkit-border-radius: 0px !important; | |
mox-border-radius: 0px !important; | |
border-radius: 0px !important; | |
border-top: none !important; | |
border-right: none !important; | |
border-left: none !important; | |
border-bottom: 1px dotted #333 !important; | |
background: transparent; | |
box-shadow: none; | |
letter-spacing: 0; | |
resize: none; | |
word-wrap: break-word; | |
} | |
.b-autocomplete-activation-area-add-text { | |
display: block; | |
float: left; | |
color: #505050; | |
margin-top: 3px; | |
} | |
</style> | |
<!-- hidden but needed to initialize the widget --> | |
<div class="b-autocomplete-data-source-wrapper"> | |
<content select=".data-source"></content> | |
</div> | |
<a href="#" on-mouse-down="activate($event)" | |
on-touch-start="activate($event)" class="b-autocomplete-prefix-area-wrapper"> | |
<content select=".prefix-area"></content> | |
</a> | |
<div class="b-autocomplete-main-area q-autocomplete-main-area"> | |
<a href="#" class="b-autocomplete-activation-area q-autocomplete-activation-area" on-click="activate($event)" | |
on-touch-start="activate($event)"> | |
<span class="b-autocomplete-activation-area-add-text" | |
style="font-size: {{ fontSize }}"> | |
{{ addText }} | |
</span> | |
</a> | |
<template instantiate if="_state == State.ACTIVE"> | |
<form id="b-autocomplete-form" on-submit="_select($event)"> | |
<input type="text" | |
bind-value="_filterQuery" | |
on-input="_updateFilteredEntries()" | |
on-focus="_focused()" | |
on-blur="_blurred()" | |
class="q-autocomplete-form-input b-autocomplete-form-input" | |
placeholder="{{placeholder}}" | |
style="font-size: {{ fontSize }}"> | |
</form> | |
{{ focusOnInput() }} | |
</template> | |
<template instantiate if="_filteredEntries.isNotEmpty"> | |
<ul class="b-autocomplete-results" style="max-height: {{ maxHeight }}"> | |
<template iterate="entry in _filteredEntries"> | |
<template instantiate if="entry == _activeEntry"> | |
<!-- mouse-down is used instead of click to make sure it is | |
captured before the blur event is fire --> | |
<li class="b-autocomplete-results-li b-autocomplete-active" | |
on-mouse-down="_select($event)" | |
on-touch-start="_select($event)">{{ entry.sanitizedHtml }}</li> | |
</template> | |
<template instantiate if="entry != _activeEntry"> | |
<li class="b-autocomplete-results-li" | |
on-mouse-over="_setToActiveEntry(entry)" | |
on-click="_select($event)" | |
on-mouse-down="_select($event)">{{ entry.sanitizedHtml }}</li> | |
</template> | |
</template> | |
</ul> | |
</template> | |
</div> | |
</template> | |
<script type="application/dart" src="autocomplete.dart"></script> | |
</element> | |
</body></html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment