Skip to content

Instantly share code, notes, and snippets.

@davekaro
Forked from fractaledmind/multiselect.css
Last active May 6, 2021 15:55
Show Gist options
  • Save davekaro/1058da157434b6290595f77abd3473c1 to your computer and use it in GitHub Desktop.
Save davekaro/1058da157434b6290595f77abd3473c1 to your computer and use it in GitHub Desktop.
Accessible multiselect widget (based on https://www.24a11y.com/2019/select-your-poison-part-2/)
<div data-controller="multi-select">
<%= form.label(method, id: "#{id_base}__label", class: "funky-label") %>
<%# used as descriptive text for option buttons; if used within the button text itself, it ends up being read with the input name %>
<span id="<%= "#{id_base}__remove" %>" class="hidden">remove</span>
<ul id="<%= "#{id_base}__selected" %>" class="space-x-2 selected-options">
<template id="<%= "#{id_base}__selected-option-template" %>">
<li>
<%= form.hidden_field(method, multiple: true, value: nil) %>
<button class="space-x-1 btn-rounded" aria-describedby="<%= "#{id_base}__remove" %>">
<span class="combobox-remove-text"></span>
<span class="combobox-remove-icon">×</span>
</button>
</li>
</template>
<% preselected_options.each_with_index do |selected_option, i| %>
<li>
<%= form.hidden_field(method, multiple: true, value: selected_option) %>
<button id="<%= "#{id_base}-remove-#{i}" %>" class="space-x-1 btn-rounded" aria-describedby="<%= "#{id_base}__remove" %>">
<span class="combobox-remove-text"><%= selected_option %></span>
<span class="combobox-remove-icon">×</span>
</button>
</li>
<% end %>
</ul>
<div class="mb-0 combo js-multi-buttons">
<div class="input-wrapper" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-owns="listbox">
<%= form.text_field(method, value: nil, name: nil, id: id_base.to_s, class: "form-control", placeholder: "Start typing...", aria: {activedescendant: nil, autocomplete: "list", labelledby: "#{id_base}__label #{id_base}__selected"}) %>
</div>
<div id="<%= "#{id_base}__listbox" %>" class="combo-menu" role="listbox" aria-multiselectable="true">
<% choices.each_with_index do |option, i| %>
<div
id="<%= "#{id_base}__combobox-#{i}" %>"
class="<%= class_names("combo-option", "option-current" => i.zero?, "option-selected" => preselected_options.include?(option)) %>"
role="option"
aria-selected="<%= preselected_options.include?(option) ? "true" : "false" %>"
>
<%= option %>
</div>
<% end %>
</div>
</div>
</div>
class TailwindForm::MultiSelectMenuComponent < TailwindForm::BaseComponent
renders_one :label
renders_one :help_text
attr_reader :id_base, :preselected_options, :choices, :label_html, :required
def initialize(form, object_name, method, choices = nil, options = {})
super(form, options[:object], object_name, method)
@choices = choices
@required = options[:required]
@id_base = "combobox-#{method}"
@preselected_options = form.object.public_send(method)
end
end
import { Controller } from "stimulus";
/*
* Helper constants and functions
*/
// make it easier for ourselves by putting some values in objects
// in TypeScript, these would be enums
const Keys = {
Backspace: "Backspace",
Clear: "Clear",
Down: "ArrowDown",
End: "End",
Enter: "Enter",
Escape: "Escape",
Home: "Home",
Left: "ArrowLeft",
PageDown: "PageDown",
PageUp: "PageUp",
Right: "ArrowRight",
Space: " ",
Tab: "Tab",
Up: "ArrowUp",
};
const MenuActions = {
Close: 0,
CloseSelect: 1,
First: 2,
Last: 3,
Next: 4,
Open: 5,
Previous: 6,
Select: 7,
Space: 8,
Type: 9,
};
// filter an array of options against an input string
// returns an array of options that begin with the filter string, case-independent
function filterOptions(options = [], filter, exclude = []) {
return options.filter((option) => {
const matches = option.toLowerCase().indexOf(filter.toLowerCase()) === 0;
return matches && exclude.indexOf(option) < 0;
});
}
// return an array of exact option name matches from a comma-separated string
function findMatches(options, search) {
const names = search.split(",");
return names
.map((name) => {
const match = options.filter(
(option) => name.trim().toLowerCase() === option.toLowerCase()
);
return match.length > 0 ? match[0] : null;
})
.filter((option) => option !== null);
}
// return combobox action from key press
function getActionFromKey(key, menuOpen) {
// handle opening when closed
if (!menuOpen && key === Keys.Down) {
return MenuActions.Open;
}
// handle keys when open
if (key === Keys.Down) {
return MenuActions.Next;
} else if (key === Keys.Up) {
return MenuActions.Previous;
} else if (key === Keys.Home) {
return MenuActions.First;
} else if (key === Keys.End) {
return MenuActions.Last;
} else if (key === Keys.Escape) {
return MenuActions.Close;
} else if (key === Keys.Enter) {
return MenuActions.CloseSelect;
} else if (key === Keys.Backspace || key === Keys.Clear || key.length === 1) {
return MenuActions.Type;
}
}
// get index of option that matches a string
function getIndexByLetter(options, filter) {
const firstMatch = filterOptions(options, filter)[0];
return firstMatch ? options.indexOf(firstMatch) : -1;
}
// get updated option index
function getUpdatedIndex(current, max, action) {
switch (action) {
case MenuActions.First:
return 0;
case MenuActions.Last:
return max;
case MenuActions.Previous:
return Math.max(0, current - 1);
case MenuActions.Next:
return Math.min(max, current + 1);
default:
return current;
}
}
// check if an element is currently scrollable
function isScrollable(element) {
return element && element.clientHeight < element.scrollHeight;
}
// ensure given child element is within the parent's visible scroll area
function maintainScrollVisibility(activeElement, scrollParent) {
const { offsetHeight, offsetTop } = activeElement;
const { offsetHeight: parentOffsetHeight, scrollTop } = scrollParent;
const isAbove = offsetTop < scrollTop;
const isBelow = offsetTop + offsetHeight > scrollTop + parentOffsetHeight;
if (isAbove) {
scrollParent.scrollTo(0, offsetTop);
} else if (isBelow) {
scrollParent.scrollTo(0, offsetTop - parentOffsetHeight + offsetHeight);
}
}
/*
* MultiSelect Combobox w/ Buttons code
*/
const MultiSelectButtons = function (el) {
// element refs
this.el = el;
this.comboEl = el.querySelector("[role=combobox]");
this.inputEl = el.querySelector('input[aria-autocomplete="list"]');
this.listboxEl = el.querySelector("[role=listbox]");
this.idBase = this.inputEl.id;
this.selectedEl = document.getElementById(`${this.idBase}__selected`);
this.selectedOptionTemplate = document.getElementById(
`${this.idBase}__selected-option-template`
);
this.preselectedOptionButtons = Array.from(
this.selectedEl.querySelectorAll("li button")
);
// data
this.optionEls = Array.from(this.listboxEl.children);
this.options = this.optionEls.map((option) => option.innerText.trim());
this.filteredOptions = this.options;
// state
this.activeIndex = 0;
this.open = false;
};
MultiSelectButtons.prototype.init = function () {
this.inputEl.addEventListener("input", this.onInput.bind(this));
this.inputEl.addEventListener("blur", this.onInputBlur.bind(this));
this.inputEl.addEventListener("click", () => this.updateMenuState(true));
this.inputEl.addEventListener("keydown", this.onInputKeyDown.bind(this));
this.optionEls.map((optionEl, index) => {
optionEl.addEventListener("click", () => {
this.onOptionClick(index);
});
optionEl.addEventListener("mousedown", this.onOptionMouseDown.bind(this));
});
console.log(this.preselectedOptionButtons);
this.preselectedOptionButtons.map((buttonEl, index) => {
var optionValue = buttonEl
.querySelector(".combobox-remove-text")
.innerText.trim();
var optionIndex = this.options.indexOf(optionValue);
console.log(buttonEl, index, optionValue, optionIndex);
buttonEl.addEventListener("click", () => {
this.removeOption(index);
});
});
};
MultiSelectButtons.prototype.filterOptions = function (value) {
this.filteredOptions = filterOptions(this.options, value);
// hide/show options based on filtering
const options = this.el.querySelectorAll("[role=option]");
Array.from(options).forEach((optionEl) => {
const value = optionEl.innerText;
if (this.filteredOptions.indexOf(value) > -1) {
optionEl.style.display = "block";
} else {
optionEl.style.display = "none";
}
});
};
MultiSelectButtons.prototype.onInput = function () {
const curValue = this.inputEl.value;
this.filterOptions(curValue);
// if active option is not in filtered options, set it to first filtered option
if (this.filteredOptions.indexOf(this.options[this.activeIndex]) < 0) {
const firstFilteredIndex = this.options.indexOf(this.filteredOptions[0]);
this.onOptionChange(firstFilteredIndex);
}
const menuState = this.filteredOptions.length > 0;
if (this.open !== menuState) {
this.updateMenuState(menuState, false);
}
};
MultiSelectButtons.prototype.onInputKeyDown = function (event) {
const { key } = event;
const max = this.filteredOptions.length - 1;
const activeFilteredIndex = this.filteredOptions.indexOf(
this.options[this.activeIndex]
);
const action = getActionFromKey(key, this.open);
switch (action) {
case MenuActions.Next:
case MenuActions.Last:
case MenuActions.First:
case MenuActions.Previous:
event.preventDefault();
const nextFilteredIndex = getUpdatedIndex(
activeFilteredIndex,
max,
action
);
const nextRealIndex = this.options.indexOf(
this.filteredOptions[nextFilteredIndex]
);
return this.onOptionChange(nextRealIndex);
case MenuActions.CloseSelect:
event.preventDefault();
return this.updateOption(this.activeIndex);
case MenuActions.Close:
event.preventDefault();
return this.updateMenuState(false);
case MenuActions.Open:
return this.updateMenuState(true);
}
};
MultiSelectButtons.prototype.onInputBlur = function () {
if (this.ignoreBlur) {
this.ignoreBlur = false;
return;
}
if (this.open) {
this.updateMenuState(false, false);
}
};
MultiSelectButtons.prototype.onOptionChange = function (index) {
this.activeIndex = index;
this.inputEl.setAttribute("aria-activedescendant", `${this.idBase}-${index}`);
// update active style
const options = this.el.querySelectorAll("[role=option]");
Array.from(options).forEach((optionEl) => {
optionEl.classList.remove("option-current");
});
options[index].classList.add("option-current");
if (this.open && isScrollable(this.listboxEl)) {
maintainScrollVisibility(options[index], this.listboxEl);
}
};
MultiSelectButtons.prototype.onOptionClick = function (index) {
this.onOptionChange(index);
this.updateOption(index);
this.inputEl.focus();
};
MultiSelectButtons.prototype.onOptionMouseDown = function () {
this.ignoreBlur = true;
};
MultiSelectButtons.prototype.removeOption = function (index) {
const option = this.options[index];
// update aria-selected
const options = this.el.querySelectorAll("[role=option]");
options[index].setAttribute("aria-selected", "false");
options[index].classList.remove("option-selected");
// remove button
const buttonEl = document.getElementById(`${this.idBase}-remove-${index}`);
this.selectedEl.removeChild(buttonEl.parentElement);
};
MultiSelectButtons.prototype.selectOption = function (index) {
const selected = this.options[index];
this.activeIndex = index;
// update aria-selected
const options = this.el.querySelectorAll("[role=option]");
options[index].setAttribute("aria-selected", "true");
options[index].classList.add("option-selected");
// add remove option button
const listItem = this.selectedOptionTemplate.content.cloneNode(true);
const buttonEl = listItem.querySelector("button");
const spanEl = buttonEl.querySelector("span");
const inputEl = listItem.querySelector('input[type="hidden"]');
buttonEl.id = `${this.idBase}-remove-${index}`;
buttonEl.addEventListener("click", () => {
this.removeOption(index);
});
spanEl.innerHTML = selected;
inputEl.value = selected;
this.selectedEl.appendChild(listItem);
};
MultiSelectButtons.prototype.updateOption = function (index) {
const option = this.options[index];
const optionEls = this.el.querySelectorAll("[role=option]");
const optionEl = optionEls[index];
const isSelected = optionEl.getAttribute("aria-selected") === "true";
if (isSelected) {
this.removeOption(index);
} else {
this.selectOption(index);
}
this.inputEl.value = "";
this.filterOptions("");
};
MultiSelectButtons.prototype.updateMenuState = function (
open,
callFocus = true
) {
this.open = open;
this.comboEl.setAttribute("aria-expanded", `${open}`);
open ? this.el.classList.add("open") : this.el.classList.remove("open");
callFocus && this.inputEl.focus();
};
export default class extends Controller {
connect() {
const multiButtonComponent = new MultiSelectButtons(this.element, []);
multiButtonComponent.init();
}
}
.combo {
display: block;
margin-bottom: 1.5em;
// max-width: 400px;
position: relative;
}
.combo::after {
border-bottom: 2px solid rgba(0,0,0,.5);
border-right: 2px solid rgba(0,0,0,.5);
content: '';
display: block;
height: 12px;
pointer-events: none;
position: absolute;
right: 16px;
top: 50%;
transform: translate(0, -65%) rotate(45deg);
width: 12px;
}
.input-wrapper {
border-radius: 4px;
}
.combo-input {
background-color: #f5f5f5;
border: 2px solid rgba(0,0,0,.5);
border-radius: 4px;
display: block;
font-size: 1em;
min-height: calc(1.4em + 26px);
padding: 12px 16px 14px;
text-align: left;
width: 100%;
}
select.combo-input {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
.open .form-control {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.combo-input:focus {
border-color: #0067b8;
box-shadow: 0 0 4px 2px #0067b8;
outline: 5px solid transparent;
}
.combo-label {
display: block;
font-size: 20px;
font-weight: 100;
margin-bottom: 0.25em;
}
.combo-menu {
background-color: #f5f5f5;
border: 1px solid rgba(0,0,0,.42);
border-radius: 0 0 4px 4px;
display: none;
max-height: 300px;
overflow-y:scroll;
left: 0;
position: absolute;
top: 100%;
width: 100%;
z-index: 100;
}
.open .combo-menu {
display: block;
}
.combo-option {
padding: 10px 12px 12px;
}
.combo-option.option-current,
.combo-option:hover {
background-color: rgb(198, 246, 213);
}
.combo-option.option-selected {
padding-right: 30px;
position: relative;
}
.combo-option.option-selected::after {
content: '✓';
position: absolute;
right: 0.5rem;
top: calc(50% - 1rem);
font-size: 2rem;
line-height: 2rem;
}
/* multiselect list of selected options */
.selected-options {
list-style-type: none;
margin: 0;
// max-width: 400px;
padding: 0;
}
.selected-options li {
display: inline-block;
margin-bottom: 5px;
}
.remove-option {
@apply bg-primary-500;
border-radius: 3px;
color: #fff;
font-size: 0.75em;
font-weight: bold;
margin-bottom: 6px;
margin-right: 6px;
padding: 0.25em 1.75em 0.25em 0.25em;
position: relative;
}
.remove-option:focus {
border-color: #baa1dd;
box-shadow: 0 0 3px 1px #6200ee;
outline: 3px solid transparent;
}
.remove-option::before,
.remove-option::after {
border-right: 2px solid #fff;
content: "";
height: 1em;
right: 0.75em;
position: absolute;
top: 50%;
width: 0;
}
.remove-option::before {
transform: translate(0, -50%) rotate(45deg);
}
.remove-option::after {
transform: translate(0, -50%) rotate(-45deg);
}
.multiselect-inline {
align-items: center;
background-color: #f5f5f5;
border: 2px solid rgba(0,0,0,.42);
border-radius: 4px;
display: flex;
flex-wrap: wrap;
min-height: calc(1.4em + 26px);
padding: 12px 16px 14px;
}
.multiselect-inline .selected-options {
flex: 0 1 auto;
}
.multiselect-inline .selected-options li {
margin-bottom: 0;
}
.multiselect-inline .combo-input {
border: none;
flex: 1 1 35%;
min-height: calc(1.4em - 2px);
padding: 0;
}
.multiselect-inline .combo-input:focus {
box-shadow: none;
outline: none;
}
.multiselect-inline:focus-within {
box-shadow: 0 0 3px 2px #0067b8;
outline: 5px solid transparent;
}
<%= form_with(model: @review) do |form| %>
<%= multiselect form, :recipients, current_account.users.pluck(:email) %>
<% end %>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment