Skip to content

Instantly share code, notes, and snippets.

@replete
Created February 7, 2017 15:16
Show Gist options
  • Save replete/089a5fad88d12d534baf7a23e39e2cae to your computer and use it in GitHub Desktop.
Save replete/089a5fad88d12d534baf7a23e39e2cae to your computer and use it in GitHub Desktop.
Select.js
(function (w, d, undefined) {
'use strict';
// TODO: Make some methods private for better inheritance
var h = d.documentElement;
function getNamespace(ns, names) {
for (var i = 0, n = names.split('.'), l = n.length; i < l; i++) {
if (typeof ns[n[i]] === 'undefined') ns[n[i]] = {};
ns = ns[n[i]];
}
return ns;
}
function Select(options, el) {
var _ = this;
// Configure instance
if (!el) {
el = options;
options = {};
}
for (var o in options) {
_.settings[o] = options[o];
}
// Store references
_.parent = el.parentNode; // <div class="select">
_.el = el; // <select>
_.init(_);
}
Select.prototype = {
settings: {
selectTag: 'div',
optionsTag: 'ul',
optionTag: 'li',
chosenTag: 'dl',
labelTag: 'dt',
choiceTag: 'dd',
doneButtonTag: 'div',
selectClass: 'select',
optionsClass: 'select__options',
optionClass: 'select__option',
chosenClass: 'select__chosen',
labelClass: 'select__label',
choiceClass: 'select__choice',
doneButtonClass: 'select__done',
openClass: 'is-open',
selectedClass: 'is-selected',
initClass: 'is-select',
multipleClass: 'is-multiple',
multipleChoicesClass: 'has-multiple-choices',
disabledClass: 'disabled',
restrictViewportClass: 'is-viewport-restricted',
labelAttribute: 'data-select-label',
anyRegex: /^any$|^all$/,
splitRegex: /\s\(([0-9]*)\)/,
ns: 'replete.selects',
restrictViewport: true,
showDoneButton: true,
doneButtonValue: 'Done',
hasOpenClass: 'has-select-open',
updateOnSelectionClass: 'is-search-and'
},
init: function (_) {
var s = _.settings;
// Store instance name
_.name = _.el.id || _.el.name || Date.now();
/**
* Descriptor:
* if <select data-select-label=blah>' exists, use that
* else if <label for='x'> exists, use that
* else if first <option value=''> use that
* else ''
*/
var label;
var labelText = _.el.getAttribute(s.labelAttribute) || '';
var firstOption = _.el.options[0] || d.createElement('option');
labelText = labelText.trim();
if (!labelText && _.el.id) {
label = d.querySelector('[for="' + _.el.id + '"]');
labelText = label ? label.textContent.trim() : labelText;
}
if (!labelText) {
labelText = !firstOption.value ? firstOption.textContent.trim() : labelText;
}
_.labelText = labelText;
// Enforce Any behaviour
// If [multiple] and first <option> matches regex, enforce any behaviour
_.enforceAny = _.el.multiple && s.anyRegex.test(firstOption.value);
// Update on Selection behaviour
_.updateOnSelection = _.el.classList.contains(s.updateOnSelectionClass);
// Create <div class="select">
var select = d.createElement(s.selectTag);
select.className = [
s.selectClass,
_.el.multiple ? s.multipleClass : '',
s.restrictViewport ? s.restrictViewportClass : ''
].join(' ');
if (_.el.title) {
select.title = _.el.title;
}
_.select = select;
// Store reference to Select on <select>
_.el.selectInstance = _;
// Last clicked references
_.lastClickedOptionIndex = null;
// Append to DOM
_.parent.appendChild(select);
// Bind context to eventHandlers
_.click = _.clickHandler.bind(null, _);
_.outsideClick = _.outsideClickHandler.bind(null, _);
_.change = _.changeHandler.bind(null, _);
_.key = _.keyHandler.bind(null, _);
// Apply events
select.addEventListener('click', _.click, true);
_.el.addEventListener('change', _.change);
_.el.addEventListener('keydown', _.key, true);
// Hide <select>
_.el.classList.add(s.initClass);
// Trigger first render
_.render();
// Create regex for outsideClickHandler
_.outsideClickRegex = new RegExp([
s.selectClass,
s.optionClass,
s.optionsClass,
s.chosenClass,
s.labelClass,
s.choiceClass,
s.doneButtonClass
].join('|'));
// Namespace
if (s.ns) {
try {
s.ns = eval(s.ns);
} catch (e) {
s.ns = getNamespace(w, s.ns);
}
if (!s.ns.instances) s.ns.instances = {};
s.ns.instances[_.name] = _;
}
},
render: function () {
var _ = this;
if (_.onHold) { return false; }
var s = _.settings;
// <div class="select__chosen">
var chosenFragment = d.createDocumentFragment();
var chosen = d.createElement(s.chosenTag);
chosen.className = s.chosenClass;
// <ul class="select__options">
var optionsFragment = d.createDocumentFragment();
var options = d.createElement(s.optionsTag);
options.className = s.optionsClass;
// Iterate <option>
for (var i = 0, selectedCount = -1, selectOption, text; selectOption = _.el.options[i]; i++) {
text = s.splitRegex
? selectOption.textContent.replace(s.splitRegex, '<span>$1</span>')
: selectOption.textContent;
// <li class="select__option">Option x</li>
var option = d.createElement(s.optionTag);
option.className = s.optionClass;
option.value = selectOption.value;
option.index = i;
option.innerHTML = text;
if (selectOption.selected) {
selectedCount++;
option.className += ' ' + s.selectedClass;
// <span class="select__choice">Option x</span>
var choice = d.createElement(s.choiceTag);
choice.className = s.choiceClass;
choice.innerHTML = text;
choice.value = selectOption.value;
choice.index = i;
chosenFragment.appendChild(choice);
}
if (_.lastClickedOptionIndex === i && s.showDoneButton && i > 0 && _.el.multiple) {
// Create button
var doneButton = d.createElement(s.doneButtonTag);
doneButton.className = s.doneButtonClass;
doneButton.textContent = s.doneButtonValue;
option.appendChild(doneButton);
}
optionsFragment.appendChild(option);
}
// Add class to select for multiple choices
_.select.classList[selectedCount ? 'add' : 'remove'](s.multipleChoicesClass);
// Build Label
var label = d.createElement(s.labelTag);
label.className = s.labelClass;
label.textContent = _.labelText;
chosenFragment.insertBefore(label, chosenFragment.childNodes[0]);
// Write fragments to nodes
chosen.appendChild(chosenFragment);
options.appendChild(optionsFragment);
// Reset DOM
_.select.textContent = '';
// Write nodes to DOM
_.select.appendChild(chosen);
_.select.appendChild(options);
// Fire post-render event
var renderEvent = new CustomEvent('select-postrender');
_.el.dispatchEvent(renderEvent);
if (_.el.form) {
_.el.form.dispatchEvent(renderEvent);
}
},
changeHandler: function (_) {
if (_.onHold) { return false }
if (_.enforceAny) {
switch (_.el.selectedOptions.length) {
case 0:
_.el.options[0].selected = true;
break;
case 1: break;
default:
if (_.settings.anyRegex.test(_.el.selectedOptions[0].value)) {
_.el.options[0].selected = false;
}
}
}
_.render();
// Changed event TODO: de-dupe this code
if (_.updateOnSelection) {
var changedEvent = new CustomEvent('select-change');
_.el.dispatchEvent(changedEvent);
if (_.el.form) {
_.el.form.dispatchEvent(changedEvent);
}
}
_.wasChanged = true;
},
clickHandler: function (_, event) {
var s = _.settings;
var target = event.target;
var isOption = target.classList.contains(s.optionClass);
var isDisabled = _.el.disabled;
// Focus element
_.el.focus();
if (isDisabled || _.onHold) {
return false;
}
// TODO: Allow multiple modes
if (isOption) {
_.lastClickedOptionIndex = target.index;
if (target.index === 0 && _.enforceAny) {
var i = _.el.selectedOptions.length;
while (i--) {
_.el.selectedOptions[i].selected = false;
}
setTimeout(_.toggle.bind(_, false), 1000);
} else {
_.el.options[target.index].selected = !target.classList.contains(s.selectedClass);
}
_.change();
if (!_.el.multiple) {
_.toggle(false);
}
} else if (
target.classList.contains(s.chosenClass) && _.select.classList.contains(s.openClass)
|| target.classList.contains(s.doneButtonClass)
) {
_.toggle(false)
} else {
_.toggle(true);
}
},
outsideClickHandler: function (_, event) {
if (!_.outsideClickRegex.test(event.target.className)) {
_.toggle(false);
}
},
keyHandler: function (_, event) {
switch (event.keyCode) {
case 27: // ESC
case 13: // ENTER
case 8: // BACKSPACE
_.toggle(false);
break;
}
},
toggle: function (newState) {
var _ = this;
var s = _.settings;
var classList = _.select.classList;
var isOpen = classList.contains(s.openClass);
newState = newState === undefined ? !isOpen : newState;
// Check if other dropdowns are already open TODO: improve
if (newState && h.classList.contains(s.hasOpenClass)) {
for (var instance in s.ns.instances) {
s.ns.instances[instance].toggle(false);
}
}
h.classList[newState ? 'add' : 'remove'](s.hasOpenClass);
// Handle outside clicks
d.body[newState ? 'addEventListener' : 'removeEventListener']('click', _.outsideClick, true);
// Update visual state
classList[newState ? 'add' : 'remove'](s.openClass);
// Focus <select>
if (!newState) {
_.el.focus();
}
// Changed event TODO: de-dupe this code
if (_.wasChanged) {
var changedEvent = new CustomEvent('select-change');
_.el.dispatchEvent(changedEvent);
if (_.el.form) {
_.el.form.dispatchEvent(changedEvent);
}
_.wasChanged = false;
}
// open / close event
var customEvent = new CustomEvent(newState ? 'select-open' : 'select-close');
_.el.dispatchEvent(customEvent);
if (_.el.form) {
_.el.form.dispatchEvent(customEvent);
}
},
// destroy: function () {
// var _ = this;
// var s = _.settings;
// // Remove
// if (s.ns && eval(s.ns)) {
// s.ns.instances[_.name] = null;
// }
// // TODO: Remove DOM code
// },
hold: function (toggle) {
var _ = this;
_.onHold = typeof toggle === 'undefined' ? false : toggle;
// Show onHold state with disabled styles
_.select.classList[toggle ? 'add' : 'remove'](_.settings.disabledClass);
}
};
// Expose select component
w.Select = Select;
})(window, document);
.select {
position:relative;
display:block;
cursor:default;
/*---------------------------------------
State
˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭*/
&:not(.disabled):hover {
.select__choice {
color: $BRAND;
}
}
&.disabled:focus {
.search__options{
box-shadow:none !important;
}
}
&.is-open {
overflow:visible;
.select__options {
display:block;
z-index:200;
}
.select__choice {
visibility:hidden;
}
.select__chosen {
width:auto; // Prevents y-scrollbars
&::before {
z-index:3;
display:none; // KLUDGE: Hide chevron for small selects when open
}
}
}
&.is-viewport-restricted.is-open {
&::before {
content:'';
z-index:300;
top:0;
left:0;
right:0;
height:12px;
background:linear-gradient(180deg, $LIGHT 10%, transparent 100%);
pointer-events:none;
position:absolute;
}
.select__options {
max-height:100vh;
overflow-y:auto;
@include scrollbars(12px, $FADE_FLAT, $LIGHT);
}
}
&.has-multiple-choices {
&:hover .select__chosen::after {
opacity:0 !important;
}
.select__chosen {
counter-reset: choiceCount;
&::before {
padding:0 23px 0 5px;
}
&::after {
content: "" counter(choiceCount) "";
background: $FADE_FLAT;
// color: $LIGHT;
top:50%;
transform: translateY(-50%);
right:6px;
width:15px;
height:15px;
color:$BRAND;
border:2px solid $FADE;
line-height:17px;
border-radius:100%;
font-size:11px;
font-weight:bold;
text-align:center;
display:block;
text-rendering:optimizeLegibility;
position:absolute;
opacity:1;
transition: 500ms all $EASE;
}
}
.select__choice {
counter-increment: choiceCount;
}
}
// These styles force height of 34px
& {
overflow:hidden;
height:32px;
}
/*---------------------------------------
Components
˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭*/
&__done {
background:$BRAND;
padding:0 6px;
font-size:12px;
display:inline-block;
border:0;
color: $LIGHT !important;
font-family:$FONT;
margin-left:16px;
position:absolute;
right:4px;
top:4px;
bottom:4px;
// animation: 750ms slideInRightOpacity $EASE forwards;
// animation-delay:200ms;
// opacity:0;
line-height:23px;
// .is-search-and & {
// animation:none;
// animation-delay:0;
// }
}
&__chosen {
padding-right:30px !important;
background:none !important;
overflow:hidden;
width:1000px; // Magic number
&::before {
content: '';
width:20px;
color:$GREY;
display:inline-block;
font-weight:bold;
font-size:10px;
position:absolute;
text-align:right;
top:0;
right:0;
padding:0 5px 0 0px;
line-height:32px;
height:100%;
background: url('#{$IMAGES}icons/form_caret.svg') no-repeat top right,
linear-gradient(90deg, transparent 0%, $LIGHT 35%);
}
}
// TODO: This is hidden, but will be a separate mode of replete.select.js
&__label {
display:none;
}
&__choice {
margin:0 3px 10px 0;
display:inline-block;
span {
display:none;
}
&:not(:last-child)::after {
content:', ';
}
}
&__options {
display:none;
list-style-type:none;
position:absolute;
min-width:100%;
top:0;
left:0;
@include focusShadow;
}
&__option {
color:$GREY !important;
transition: $DURATION all $EASE;
background-image:none !important;
border-color:$LIGHT !important;
white-space: pre;
position:relative;
span {
pointer-events:none;
// float:right;
font-size:10px;
// line-height:20px;
font-weight:600;
text-rendering:optimizeLegibility;
// letter-spacing:1px;
// background:$GREY_MID2;
// border:1px solid $GREY_MID2;
// color:$GREY_LIGHT;
// padding:1px 3px 1px 3px;
// border-radius:6px;
// text-align:center;
color:$GREY_MID;
margin-left:8px;
}
&:hover {
color:$BRAND;
&::before {
opacity:0.5;
}
}
&.is-selected {
color:$BRAND !important;
// font-weight:700;
}
&::before {
content: '\E037';
font-family:$FONT_ICON;
color:$BRAND;
display:inline-block;
font-size:10px;
width:1.5em;
transform: translate(0, 1px);
opacity:0;
transition: $DURATION all $EASE;
}
&.is-selected::before {
opacity:1;
}
}
}
/*---------------------------------------
<select> State
˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭˭*/
.js-select {
height:34px;
overflow:hidden;
border:0;
visibility:hidden;
}
.is-select {
background: #ddd !important;
@include visuallyhidden;
&[multiple] {
height:150px;
}
&:focus ~ .select:not(.is-open) {
@include defaultOutline;
.select__chosen {
color:$BRAND;
}
.is-mouse-user & {
transition: $DURATION all $EASE;
outline:0;
@include focusShadow;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment