DEV Community

Cover image for Jurit Multi Select Demo
ArtyProg
ArtyProg

Posted on

Jurit Multi Select Demo

Ever found yourself wrestling with building a custom, accessible Select component? Here is a version in Juris, created by the author himself.

MultiSelect

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> 
Enter fullscreen mode Exit fullscreen mode

Top comments (0)