Ever found yourself wrestling with building a custom, accessible Select component? Here is a version in Juris, created by the author himself.
Here is the full code. It runs as is in browser, pure Javascript code, no bundler, that is Juris :-)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Juris Multi-Select Demo</title>
<script src="https://unpkg.com/juris@0.88.2/juris.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
background: rgba(10, 110, 140, 1);
}
.multiselect-container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 20px;
}
.multiselect-input-container {
min-height: 27px;
padding: 8px;
border: 2px solid #e2e8f0;
border-radius: 6px;
background: white;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
cursor: text;
transition: border-color 0.2s;
}
.multiselect-input-container:focus-within {
border-color: #3b82f6;
}
.multiselect-input {
border: none;
outline: none;
font-size: 16px;
flex: 1;
min-width: 120px;
padding: 4px;
background: transparent;
}
.dropdown {
border: 1px solid #e2e8f0;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.dropdown-item {
padding: 12px;
cursor: pointer;
border-bottom: 1px solid #f1f5f9;
transition: background-color 0.15s;
}
.dropdown-item:hover,
.dropdown-item.highlighted {
background-color: #f8fafc;
}
.dropdown-item.highlighted {
background-color: #eff6ff;
}
.dropdown-item:last-child {
border-bottom: none;
}
.selected-item {
background: white;
padding: 2px 4px 4px 8px;
border-radius: 4px;
border:solid 1px grey;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
animation: slideIn 0.2s ease-out;
white-space: nowrap;
}
.remove-btn {
background: #fafafa;
border: none;
border-radius: 50%;
width: 16px;
height: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: #cecece;
transition: background-color 0.15s;
}
.remove-btn:hover {
background: grey;
}
.multiselect-wrapper {
position: relative;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.loading {
padding: 12px;
text-align: center;
color: #64748b;
}
.max-items-message {
padding: 8px 12px;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 4px;
font-size: 12px;
color: #92400e;
margin-bottom: 8px;
}
.help-button {
position: absolute;
top: -30px;
right: 0;
background: #f1f5f9;
border: 1px solid #cbd5e1;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #64748b;
transition: all 0.2s;
}
.help-button:hover {
background: #e2e8f0;
color: #374151;
}
.help-tooltip {
position: absolute;
top: -120px;
right: 0;
background: #1f2937;
color: white;
padding: 12px;
border-radius: 8px;
font-size: 12px;
width: 280px;
z-index: 1001;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.help-tooltip::after {
content: '';
position: absolute;
bottom: -6px;
right: 20px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid #1f2937;
}
.help-tooltip ul {
margin: 0;
padding-left: 16px;
}
.help-tooltip li {
margin: 4px 0;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-4px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.no-results {
padding: 12px;
color: #64748b;
font-style: italic;
text-align: center;
}
h1 {
color: #1e293b;
text-align: center;
margin-bottom: 30px;
}
.demo-info {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 6px;
padding: 16px;
margin-bottom: 20px;
color: #1e40af;
}
h1 {
color: white;
}
</style>
</head>
<body>
<div id="app"></div>
<script>
// Sample data
const fruits = [
{ id: 1, name: 'Apple', category: 'Tree fruit' },
{ id: 2, name: 'Banana', category: 'Tropical' },
{ id: 3, name: 'Cherry', category: 'Stone fruit' },
{ id: 4, name: 'Dragonfruit', category: 'Exotic' },
{ id: 5, name: 'Elderberry', category: 'Berry' },
{ id: 6, name: 'Fig', category: 'Tree fruit' },
{ id: 7, name: 'Grape', category: 'Vine fruit' },
{ id: 8, name: 'Honeydew', category: 'Melon' },
{ id: 9, name: 'Kiwi', category: 'Exotic' },
{ id: 10, name: 'Lemon', category: 'Citrus' },
{ id: 11, name: 'Mango', category: 'Tropical' },
{ id: 12, name: 'Orange', category: 'Citrus' },
{ id: 13, name: 'Papaya', category: 'Tropical' },
{ id: 14, name: 'Strawberry', category: 'Berry' },
{ id: 15, name: 'Watermelon', category: 'Melon' }
];
const MultiSelectDemo = (props, { getState, setState }) => ({
div: {
onclick: (e) => {
// Close help when clicking outside the help area
if (getState('showHelp', false) &&
!e.target.closest('.help-tooltip') &&
!e.target.closest('.help-button')) {
setState('showHelp', false);
}
},
children: [
{h1: { text: 'Juris Multi-Select Component Demo' }},
{div: {class: 'demo-info',
text: '🚀 Built with Juris.js - Object-First Reactive Framework. Try typing to search, click to select, and use × to remove items!'
}},
{div: {class: 'multiselect-container',children: [
{ MultiSelectComponent: {} }
]}}
]
}//div
});
const MultiSelectComponent = (props, { getState, setState }) => ({
div: {
class: 'multiselect-wrapper',
role: 'combobox',
'aria-expanded': () => getState('isDropdownOpen', false),
'aria-haspopup': 'listbox',
'aria-label': 'Multi-select fruits',
children: [
// Help button
{
button: {
class: 'help-button',
style: { display: "none" },
type: 'button',
title: 'Show keyboard shortcuts',
'aria-label': 'Show help and keyboard shortcuts',
text: '?',
onclick: () => {
const isOpen = getState('showHelp', false);
setState('showHelp', !isOpen);
}
}
},
// Help tooltip
{
div: {
class: 'help-tooltip',
style: () => ({
display: getState('showHelp', false) ? 'block' : 'none'
}),
tabindex: '0',
onkeydown: (e) => {
if (e.key === 'Escape') {
setState('showHelp', false);
}
},
children: [
{ div: { text: 'Keyboard Shortcuts:', style: { fontWeight: 'bold', marginBottom: '8px' } } },
{ ul: { children: [
{ li: { text: '↓↑ Navigate options' } },
{ li: { text: 'Enter: Select highlighted item' } },
{ li: { text: 'Escape: Close dropdown' } },
{ li: { text: 'Backspace: Remove last selected item' } },
{ li: { text: 'Type: Search and filter items' } },
{ li: { text: 'Click ×: Remove specific item' } }
]}}
]
}
},
// Max items warning
{
div: {
class: 'max-items-message',
style: () => ({
display: getState('selectedItems', []).length >= 10 ? 'block' : 'none'
}),
text: 'Maximum 10 items can be selected'
}
},
{
div: {
style: { display: "flex", gap: "0.35rem", marginBottom: "0.35em", flexWrap: "wrap" },
role: 'group',
'aria-label': 'Selected items',
children: () => {
const selectedItems = getState('selectedItems', []);
const children = [];
selectedItems.forEach((item, index) => {
children.push({
SelectedItem: { item, index }
});
});
return children }
}
},
{div: {class: 'multiselect-input-container',
onclick: (e) => {
const input = e.currentTarget.querySelector('.multiselect-input');
if (input) input.focus();
},
children: () => {
const selectedItems = getState('selectedItems', []);
const children = [];
// Add screen reader announcement
children.push({
div: {
class: 'sr-only',
'aria-live': 'polite',
'aria-atomic': 'true',
text: () => {
const count = getState('selectedItems', []).length;
return count > 0 ? `${count} items selected` : 'No items selected';
}
}
});
// Add the input field
children.push({
input: {
type: 'text',
class: 'multiselect-input',
placeholder: selectedItems.length === 0 ? 'Search and select fruits...' : '',
value: () => getState('searchTerm', ''),
role: 'textbox',
'aria-autocomplete': 'list',
'aria-describedby': 'multiselect-help',
'aria-activedescendant': () => {
const highlighted = getState('highlightedIndex', -1);
return highlighted >= 0 ? `item-${highlighted}` : null;
},
oninput: (e) => {
setState('searchTerm', e.target.value);
setState('isDropdownOpen', true);
setState('highlightedIndex', -1);
},
onfocus: () => setState('isDropdownOpen', true),
onblur: () => {
setTimeout(() => setState('isDropdownOpen', false), 150);
},
onkeydown: (e) => {
const currentSelected = getState('selectedItems', []);
const isOpen = getState('isDropdownOpen', false);
const highlighted = getState('highlightedIndex', -1);
const availableItems = getState('availableItems', []);
const searchTerm = getState('searchTerm', '').toLowerCase();
const selectedIds = currentSelected.map(item => item.id);
const filteredItems = availableItems.filter(item =>
item.name.toLowerCase().includes(searchTerm) &&
!selectedIds.includes(item.id)
);
switch(e.key) {
case 'Backspace':
if (e.target.value === '' && currentSelected.length > 0) {
setState('selectedItems', currentSelected.slice(0, -1));
}
break;
case 'ArrowDown':
e.preventDefault();
setState('showHelp', false); // Hide help when navigating
if (!isOpen) {
setState('isDropdownOpen', true);
setState('highlightedIndex', 0);
} else {
const newIndex = Math.min(highlighted + 1, filteredItems.length - 1);
setState('highlightedIndex', newIndex);
}
break;
case 'ArrowUp':
e.preventDefault();
setState('showHelp', false); // Hide help when navigating
if (isOpen && highlighted > 0) {
setState('highlightedIndex', highlighted - 1);
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && highlighted >= 0 && filteredItems[highlighted]) {
const item = filteredItems[highlighted];
if (currentSelected.length < 10) {
setState('selectedItems', [...currentSelected, item]);
setState('searchTerm', '');
setState('isDropdownOpen', false);
setState('highlightedIndex', -1);
}
}
break;
case 'Escape':
e.preventDefault();
setState('isDropdownOpen', false);
setState('highlightedIndex', -1);
setState('showHelp', false); // Also hide help on escape
break;
case 'F1':
e.preventDefault();
setState('showHelp', !getState('showHelp', false));
break;
}
}
}
});
// Help text
children.push({
div: {
id: 'multiselect-help',
class: 'sr-only',
text: 'Use arrow keys to navigate, Enter to select, Escape to close, Backspace to remove last item'
}
});
return children;
}}
},
{DropdownList: {}}
]
}
});
const DropdownList = (props, { getState, setState }) => ({
div: {
class: 'dropdown',
role: 'listbox',
'aria-label': 'Available fruits',
style: () => ({
display: getState('isDropdownOpen', false) ? 'block' : 'none'
}),
children: () => {
const searchTerm = getState('searchTerm', '').toLowerCase();
const selectedItems = getState('selectedItems', []);
const availableItems = getState('availableItems', []);
const highlightedIndex = getState('highlightedIndex', -1);
const isLoading = getState('isLoading', false);
if (isLoading) {
return [{
div: {class: 'loading', text: 'Loading...'}
}];
}
// Filter items based on search and exclude already selected
const selectedIds = selectedItems.map(item => item.id);
const filteredItems = availableItems.filter(item =>
item.name.toLowerCase().includes(searchTerm) &&
!selectedIds.includes(item.id)
);
if (filteredItems.length === 0) {
return [{
div: {
class: 'no-results',
role: 'option',
text: searchTerm ? 'No matching fruits found' : 'All fruits selected'
}
}];
}
return filteredItems.map((item, index) => ({
div: {
class: () => `dropdown-item${highlightedIndex === index ? ' highlighted' : ''}`,
id: `item-${index}`,
role: 'option',
'aria-selected': 'false',
text: `${item.name} - ${item.category}`,
onclick: () => {
const currentSelected = getState('selectedItems', []);
if (currentSelected.length < 10) {
setState('selectedItems', [...currentSelected, item]);
setState('searchTerm', '');
setState('isDropdownOpen', false);
setState('highlightedIndex', -1);
}
},
onmouseenter: () => setState('highlightedIndex', index)
}
}));
}
}
})
const SelectedItem = (props, { getState, setState }) => ({
div: {
class: 'selected-item',
role: 'group',
'aria-label': `Selected: ${props.item.name}`,
children: [
{span: { text: props.item.name }},
{button: {
class: 'remove-btn',
text: '×',
type: 'button',
'aria-label': `Remove ${props.item.name}`,
title: `Remove ${props.item.name}`,
onclick: () => {
const currentSelected = getState('selectedItems', []);
const updatedSelected = currentSelected.filter(
selectedItem => selectedItem.id !== props.item.id
);
setState('selectedItems', updatedSelected);
}
}}
]
}
})
// Initialize Juris app
const app = new Juris({
states: {
searchTerm: '',
isDropdownOpen: false,
selectedItems: [],
availableItems: fruits,
highlightedIndex: -1,
isLoading: false,
showHelp: false
},
components: {
MultiSelectDemo,
MultiSelectComponent,
DropdownList,
SelectedItem
},
layout: [
{ MultiSelectDemo: {} }
]
});
// Render the app
app.render('#app');
// Add some initial selected items for demo
setTimeout(() => {
app.setState('selectedItems', [
{ id: 1, name: 'Apple', category: 'Tree fruit' },
{ id: 11, name: 'Mango', category: 'Tropical' }
]);
}, 500);
</script>
</body>
</html>
Top comments (0)