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)