Skip to content

Instantly share code, notes, and snippets.

@dmorosinotto
Last active December 3, 2021 11:57
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 dmorosinotto/3fbc78f1b2562bf4bf3226f829466158 to your computer and use it in GitHub Desktop.
Save dmorosinotto/3fbc78f1b2562bf4bf3226f829466158 to your computer and use it in GitHub Desktop.
AngularJS multi-combo directive working with mouse & keyboard + simple responsive style based on font-size - TRY LIVE SAMPLE: https://stackblitz.com/edit/web-platform-jjfqgs?file=index.html
angular.module("multiCombo",[])
.directive("multiCombo", function () {
return {
restrict: 'E',
scope: {
value: '=', //VALORE MESSO IN BINDING CON LA SELECT USATO PER INIZIALIZZARE/LEGGERE VALORE TORNATO TIPO: T[valProp] | Array<T[valProp]> SE MULTIPLE
onSelect: '&', //EVENTO NOTIFICA VALORE CAMBIATO onSelect="handle(value)" E' AGGANCIATO AL ng-change DEL <select>
list: '=', //ARRAY DEGLI ITEM DA VISUALIZZARE: T[]
//ATTRIBUTI OPZIONALI
multiple: '@', //BOOLENAO PER INDICARE SE SI VUOLE SELEZIONE MULTIPLA O SINGOLA
size: '@', //NUMERO DI ELEMENTI DI MOSTRARE NELLA DROPDOWN LIST DEFAULT 10
//CONFIGURAZIONI OPTIONALI
valProp: '@', //NOME DELLA PROPRIETA DA USARE COME VALORE DELL'<option> (SE NON PASSATO USA INTERA ITEM: T)
txtProp: '@', //NOME DELLA PROPRIETA DA USARE COME TESTO DELL'<option> (SE NON PASSATO USA valueProp)
fmtValue: '<', //EVENTUALE FUNZIONE DI FORMATTAZIONE DEL value -> SE NON PASSO (v: T|T[]|null)=>string FORMATTA CON String() + join(',') SE MULTIPLE
empty: '@', //EVENTUALE STRINGA DA VISUALIZZARE SE NON HO SELEZIONATO NULLA value=null + DEFAULT FALLBACK '--'
},
template: function (tElem, tAttr) {
var hasMultiple = ('multiple' in tAttr) && String(tAttr['multiple']).toLowerCase() != 'false';
return `<div class="multiCombo" ng-mouseleave="close()">
<button type="button" ng-click="toggleOpen()" title="{{open ? 'Close list' : (empty || 'Choose from list...')}}">{{ (fmtValue && fmtValue(value)) || empty || '--'}}<i class="{{open ? 'icon-chevron-up': 'icon-chevron-down'}}"></i></button>
<datalist ng-show="open">
<input type="search" ng-model="search" ng-model-options="{debounce: 250}" placeholder="Filter list..." tabindex="0" ng-keydown="onTabEnter($event)">
<i class="icon-delete red" title="Clear selection" ng-click="close(true)"></i>
</input>
<select ${ hasMultiple ? 'multiple' : ''} size="${tAttr['size'] || 10}" ng-model="value" ng-change="onSelect({value: value});" ng-click="${!hasMultiple} && close()" tabindex="-1" ng-keydown="onTabEnter($event,true)">
<option ng-hide="true" value="">{{empty||'--'}}</option>
<option ng-repeat="item in listFiltered" ng-value="valProp ? item[valProp] : item">{{ (txtProp || valProp) ? item[(txtProp||valProp)] : item }}</option>
</select>
</datalist>
</div>`
},
controller: function ($scope, $element) {
var btn = $element.find("button")[0];
var inp = $element.find("input" )[0];
var sel = $element.find("select")[0];
var isMultiple = $scope.multiple!=null && String($scope.multiple).toLowerCase() != 'false';
//INIZIALIZZAZIONE FUNZIONE DI FORMATTAZIONE STANDARD value => string SE NON PASSATA! GESTISCE SIA CASO SINGOLO CHE ARRAY DI SELEZIONATI
if (typeof $scope.fmtValue != 'function')
$scope.fmtValue = function (v/*: null|string|string[] */) {
if (Array.isArray(v)) return v.length ? v.join(', ') : '';
else return !!v ? String(v) : '';
}
$scope.search = "";
$scope.listFiltered = [];
$scope.filterBy = function (list, search) {
if (!list) return [];
if (!search) {
return list;
} else {
var text = search.toLowerCase().trim();
var prop = $scope.txtProp || $scope.valProp || '';
return list.filter(item => (!prop ? JSON.stringify(item) : String(item[prop])).toLowerCase().includes(text));
}
}
var watcher = $scope.$watchGroup(['list', 'search'], ([list, search]) => {
//listFiltered VIENE COMPUTATO IN AUTOMATICO QUANDO CAMBIA search E/O list
$scope.listFiltered = $scope.filterBy(list, search);
});
$scope.$on('$destroy', function () {
//cleanup watcher
if (watcher) watcher();
if (btn) btn = null;
if (inp) inp = null;
if (sel) sel = null;
});
$scope.toggleOpen = function () {
var opened = $scope.open = !$scope.open;
if (opened) {
$scope.search = '';
try { setTimeout(()=>inp.focus(), 50); } catch { };
}
}
$scope.close = function(clear) {
if (clear) $scope.value = null;
$scope.open = false;
try { setTimeout(() => btn.focus(), 50); } catch { };
}
$scope.onTabEnter = function (e, close) {
switch (e.key) {
case 'Tab':
case 'Enter':
e.preventDefault(); e.stopPropagation();
if (close) $scope.close();
else try {
if (!($scope.value && $scope.value.length)) { //SE NON HO SELEZIONATO NULLA
var first = $scope.filterBy($scope.list, $scope.search)[0];
if (first) {
var val = $scope.valProp ? first[$scope.valProp] : first;
$scope.value = isMultiple ? [val] : val; //PRESELEZIONO PRIMO ELEMENTO
}
}
setTimeout(() => sel.focus(), 50);
} catch { };
}
}
}
}
});
multi-combo {
display: block;
}
.multiCombo {
position: relative;
}
.multiCombo button {
display: inline-block;
width: 100%;
line-height: 1.5em;
padding: 0 0.5em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 1em;
border: 1px solid gray;
border-radius: 3px;
}
.multiCombo button i {
position: absolute;
bottom: 0;
right: 0;
border: 0;
padding: 0.3em;
}
.multiCombo datalist {
position: absolute;
display: block;
/*top: 1.4em;*/
left: 0;
right: 0;
z-index: 1;
height: 14em;
border: 1px solid gray;
}
.multiCombo datalist input[type=search] {
position: absolute;
height: 1.4em;
width: 100%;
top: 0;
left: 0;
right: 0;
font-size: 1em;
padding: 0 0.5em;
}
.multiCombo datalist i {
position: absolute;
top: 0;
right: 0;
border: 0;
padding: 0.2em;
cursor: pointer;
}
.multiCombo datalist select {
position: absolute;
padding: 0 0.5em;
width: 100%;
top: 1.4em;
left: 0;
right: 0;
font-size: 1em;
}
i[class*=" icon-"], [class^=icon-] {
font-family: icomoon;
speak: none;
font-style: normal;
font-weight: 400;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
i.red {
color: red;
}
<!-- TRY LIVE SAMPLE: https://stackblitz.com/edit/web-platform-jjfqgs?file=index.html -->
<html ng-app="sample">
<head>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
<script src="multiCombo.directive.js"></script>
<link rel="stylesheet" href="multiCombo.style.css" />
</head>
<body>
<h1>SAMPLE USECASE OF <code>multi-combo</code></h1>
<div ng-controller="SampleController">
<form ng-init="flt={}">
<label>Select collection:</label>
<multi-combo
list="cols"
fmt-value="showTitle"
txt-prop="title"
value="flt.coll"
on-select="Log('SET COLLECTION', value)"
></multi-combo>
<multi-combo
style="text-align: lefts"
list="cols"
val-prop="identifier"
value="flt.coll.identifier"
on-select="Log('SET ID',value)"
></multi-combo>
<label>Select tags:</label>
<multi-combo
style="font-size: 20px"
list="tags"
val-prop="title"
multiple
value="flt.tags"
on-select="Log('SET TAGS', value)"
></multi-combo>
<pre>{{flt | json }}</pre>
</form>
</div>
<script>
angular
.module('sample', ['multiCombo'])
.controller('SampleController', function ($scope) {
//PRELOAD TAGS AND COLS
function FAKE(n, name) {
//GENERATE FAKE Array<{identifier: number, title: string}>
return new Array(5)
.fill(name + ' ')
.map((n, i) => ({ identifier: i + Math.random(), title: n + i }));
}
$scope.tags = FAKE(20, 'Tag');
$scope.cols = FAKE(10, 'Collection');
$scope.Log = function (msg, value) {
console.warn(msg, value, typeof value);
};
$scope.showTitle = function (item) {
console.dir(item);
return item && item.title;
};
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment