Skip to content

Instantly share code, notes, and snippets.

@nikgraf
Created April 12, 2014 20:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nikgraf/10554605 to your computer and use it in GitHub Desktop.
Save nikgraf/10554605 to your computer and use it in GitHub Desktop.
Web UI Autocomplete in Blossom
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 &#8230;";
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 {}; }
}
}
<!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