Created
February 7, 2017 15:16
-
-
Save replete/089a5fad88d12d534baf7a23e39e2cae to your computer and use it in GitHub Desktop.
Select.js
This file contains hidden or 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
(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); |
This file contains hidden or 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
.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