Created
May 20, 2025 01:16
-
-
Save secemp9/792b8eb0d2540d5eb2912b5bc1e73e07 to your computer and use it in GitHub Desktop.
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
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Chips Filter Demo</title> | |
<style> | |
body { | |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; | |
margin: 20px; | |
background-color: #f0f2f5; | |
color: #1c1e21; | |
line-height: 1.5; | |
} | |
.chips-filter-component { | |
margin-bottom: 20px; | |
position: relative; /* For dropdown positioning */ | |
} | |
.chips-input-container { | |
background-color: #fff; | |
border: 1px solid #ccd0d5; | |
border-radius: 6px; | |
padding: 2px 5px 2px 10px; /* top right bottom left */ | |
display: flex; | |
flex-wrap: wrap; /* Allows chips to wrap */ | |
align-items: center; /* Vertically align items if chips/input have different heights */ | |
cursor: text; | |
min-height: 38px; /* Typical input height */ | |
box-sizing: border-box; | |
} | |
.chips-input-container:focus-within { | |
border-color: #1877f2; /* Facebook blue for focus */ | |
box-shadow: 0 0 0 2px rgba(24, 119, 242, .2); | |
} | |
.chips-wrapper { | |
display: flex; | |
flex-wrap: wrap; /* Allows chips to wrap */ | |
align-items: center; | |
gap: 6px; /* Spacing between chips */ | |
padding: 4px 0; /* Vertical padding for chips if they wrap */ | |
} | |
.chip { | |
background-color: #e7f3ff; /* Light blue background */ | |
color: #1877f2; /* Facebook blue text */ | |
padding: 4px 10px; | |
border-radius: 16px; /* Pill shape */ | |
display: inline-flex; /* Use inline-flex for items inside */ | |
align-items: center; | |
font-size: 0.9em; | |
font-weight: 500; | |
line-height: 1; /* Ensure consistent height */ | |
/* margin: 2px; Chips have gap from parent wrapper now */ | |
} | |
.chip-remove { | |
background: none; | |
border: none; | |
color: #1877f2; /* Match chip text color */ | |
opacity: 0.7; | |
margin-left: 8px; | |
cursor: pointer; | |
font-size: 1.2em; /* Make X slightly larger */ | |
padding: 0 2px; /* Small padding for easier click */ | |
line-height: 1; /* Critical for alignment */ | |
display: flex; /* Center the X */ | |
align-items: center; | |
justify-content: center; | |
} | |
.chip-remove:hover { | |
opacity: 1; | |
color: #115fca; /* Darker blue on hover */ | |
} | |
.text-input { | |
flex-grow: 1; | |
border: none; | |
outline: none; | |
padding: 8px 4px; /* Vertical padding to match typical input field feel */ | |
min-width: 120px; /* Min width for usability */ | |
font-size: 0.95em; | |
background-color: transparent; | |
color: #1c1e21; | |
height: 30px; /* Ensure it takes up space even when empty */ | |
box-sizing: border-box; | |
} | |
.text-input::placeholder { | |
color: #606770; | |
} | |
.copy-chips-btn { | |
background: none; | |
border: none; | |
cursor: pointer; | |
padding: 8px; /* Clickable area */ | |
margin-left: auto; /* Push to the right */ | |
color: #606770; /* Icon color */ | |
display: flex; | |
align-items: center; | |
} | |
.copy-chips-btn svg { | |
width: 18px; | |
height: 18px; | |
vertical-align: middle; | |
} | |
.copy-chips-btn:hover { | |
color: #1877f2; /* Blue on hover */ | |
} | |
.suggestions-dropdown { | |
list-style: none; | |
padding: 4px 0; /* Padding for top/bottom of list */ | |
margin: 4px 0 0 0; /* Small margin from input */ | |
border: 1px solid #ccd0d5; | |
border-radius: 6px; | |
background-color: white; | |
max-height: 220px; /* Max height for ~6-7 items before scroll (item height ~32-36px) */ | |
overflow-y: auto; | |
position: absolute; /* Relative to .chips-filter-component */ | |
z-index: 1050; /* Bootstrap's default modal z-index is 1050, dropdowns usually slightly less or more */ | |
width: 100%; /* Make it same width as input container */ | |
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); /* Subtle shadow */ | |
box-sizing: border-box; | |
} | |
.suggestions-dropdown li { | |
padding: 8px 12px; | |
cursor: pointer; | |
font-size: 0.9em; | |
color: #1c1e21; | |
} | |
.suggestions-dropdown li:hover { | |
background-color: #f0f2f5; /* Light gray for hover */ | |
} | |
.suggestions-dropdown li.highlighted { | |
background-color: #1877f2; /* Blue for highlighted/selected */ | |
color: white; | |
} | |
.suggestions-dropdown li.is-literal { | |
/* font-style: italic; Optional: To visually mark "typed value" option */ | |
} | |
.suggestions-dropdown li.is-literal strong { /* Style for typed part of 'Add "typed text"' */ | |
font-weight: 600; | |
} | |
.controls { margin-bottom: 15px; background-color: #fff; padding: 15px; border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.1);} | |
.controls label { margin-right: 8px; font-weight: 500; } | |
.controls select { padding: 8px; border-radius: 4px; border: 1px solid #ccd0d5; font-size: 0.9em;} | |
#currentChipsOutput { background-color: #f0f2f5; padding: 10px; border-radius: 4px; font-size: 0.85em; white-space: pre-wrap; word-break: break-all; } | |
h1 { color: #1877f2; margin-bottom: 10px; font-size: 1.8em;} | |
h2 { color: #1c1e21; margin-top: 25px; margin-bottom: 10px; font-size: 1.3em; border-bottom: 1px solid #e0e0e0; padding-bottom: 5px;} | |
ul { padding-left: 20px; } | |
li { margin-bottom: 5px; } | |
</style> | |
</head> | |
<body> | |
<h1>Custom Chips Filter Demo</h1> | |
<div class="controls"> | |
<label for="columnSelect">Filter on column:</label> | |
<select id="columnSelect"> | |
<option value="productName" selected>Product Name (String)</option> | |
<option value="category">Category (String)</option> | |
<option value="supplierId">Supplier ID (Number)</option> | |
<option value="quantity">Quantity (Number)</option> | |
</select> | |
</div> | |
<div id="chipsFilterComponentContainer" class="chips-filter-component"> | |
<!-- The component will be initialized here --> | |
</div> | |
<h2>Current Active Chips (for filter query):</h2> | |
<pre id="currentChipsOutput">[]</pre> | |
<h2>Notes & Test Instructions:</h2> | |
<ul> | |
<li>Click or focus the input field. Typing in "Product Name" or "Category" (string columns) will show suggestions. Numeric columns won't query for suggestions, as specified.</li> | |
<li>Sample string data for "Product Name": Apple, Banana, Orange, Pineapple, Apple Pie, etc. Try "app", "berry".</li> | |
<li>Sample string data for "Category": Fruit, Berry, Melon, Citrus, Dessert, etc. Try "fr", "cit".</li> | |
<li>The exact typed text will be offered as a choice (e.g., "Add 'your text'"), generally as the first or second option if API suggestions exist.</li> | |
<li>API calls are simulated (150ms delay). Check browser console for "Fetching from API..." and "Serving from cache..." messages. Cache TTL is 1 min or 50 entries.</li> | |
<li>The server returns max 20 results. The dropdown displays about 6-7 items and becomes scrollable if more candidates exist.</li> | |
<li>Use Arrow Up/Down to navigate dropdown. Press Enter or Click an item to select it and create a chip.</li> | |
<li>Click the '×' on a chip to remove it. Backspace in an empty input field removes the last chip.</li> | |
<li>The copy icon (📋) copies current chip values as a comma-separated string (e.g., "Apple,Banana").</li> | |
<li>Paste text: "Val1,Val2" creates two chips. Shift+Paste "Val1,Val2" creates one chip with that exact value.</li> | |
<li>Change the selected column in the dropdown above to test behavior with different data types (chips will reset, cache will clear).</li> | |
</ul> | |
<script> | |
// --- Simulated Data & API --- | |
const allSampleData = { | |
"productName": ["Apple", "Banana", "Orange", "Pineapple", "Grape", "Strawberry", "Blueberry", "Raspberry", "Blackberry", "Watermelon", "Cantaloupe", "Honeydew", "Mango", "Papaya", "Kiwi", "Avocado", "Peach", "Plum", "Cherry", "Lemon", "Lime", "Apple Pie", "Apple Juice", "Apple Sauce", "Organic Apple"], | |
"category": ["Fruit", "Fruit", "Fruit", "Fruit", "Fruit", "Berry", "Berry", "Berry", "Berry", "Melon", "Melon", "Melon", "Tropical", "Tropical", "Tropical", "Fruit", "Stone Fruit", "Stone Fruit", "Stone Fruit", "Citrus", "Citrus", "Dessert", "Beverage", "Processed Food", "Organic Produce"], | |
"supplierId": [101, 102, 101, 103, 102, 104, 104, 104, 104, 105, 105, 105, 106, 106, 103, 101, 107, 107, 107, 108, 108, 101, 101, 101, 101], | |
"quantity": [100, 150, 200, 50, 300, 250, 180, 120, 90, 40, 60, 70, 80, 75, 110, 130, 160, 140, 190, 220, 210, 20, 30, 25, 50] | |
}; | |
const columnTypes = { | |
"productName": "string", | |
"category": "string", | |
"supplierId": "number", | |
"quantity": "number" | |
}; | |
const apiCache = { | |
data: {}, | |
ttl: 60 * 1000, // 1 minute | |
maxEntries: 50, | |
get(key) { | |
const entry = this.data[key]; | |
if (entry && (Date.now() - entry.timestamp < this.ttl)) { | |
console.info("Serving from cache:", key); | |
entry.timestamp = Date.now(); // Refresh timestamp on access (LRU-like behavior) | |
return entry.value; | |
} | |
if (entry) { | |
console.info("Cache expired for:", key); | |
delete this.data[key]; | |
} | |
return null; | |
}, | |
set(key, value) { | |
if (Object.keys(this.data).length >= this.maxEntries && !this.data[key]) { // If cache full and new key | |
const sortedKeys = Object.keys(this.data).sort((a,b) => this.data[a].timestamp - this.data[b].timestamp); | |
if (sortedKeys.length > 0) { | |
const oldestKey = sortedKeys[0]; | |
console.info("Cache full, removing oldest:", oldestKey); | |
delete this.data[oldestKey]; | |
} | |
} | |
this.data[key] = { value, timestamp: Date.now() }; | |
console.info("Cached:", key); | |
}, | |
clear() { | |
this.data = {}; | |
console.info("Cache cleared."); | |
} | |
}; | |
async function fetchSimulatedSuggestions(query, column) { | |
// API query only for string-like columns | |
if (columnTypes[column] !== 'string' || !query || String(query).trim() === '') { | |
return []; | |
} | |
const cacheKey = `${column}:${String(query).trim().toLowerCase()}`; | |
const cached = apiCache.get(cacheKey); | |
if (cached) { | |
return cached; | |
} | |
console.info(`Fetching from API for column '${column}', query '${query}'`); | |
await new Promise(resolve => setTimeout(resolve, 150)); // Simulate API delay | |
const dataForColumn = allSampleData[column] || []; | |
const results = dataForColumn | |
.filter(item => String(item).toLowerCase().includes(String(query).trim().toLowerCase())) | |
.slice(0, 20); // Limit to 20 results server-side | |
apiCache.set(cacheKey, results); | |
return results; | |
} | |
// --- ChipsFilter Class --- | |
class ChipsFilter { | |
constructor(targetElement, config = {}) { | |
this.targetElement = typeof targetElement === 'string' ? document.querySelector(targetElement) : targetElement; | |
if (!this.targetElement) { | |
console.error("ChipsFilter target element not found:", targetElement); | |
return; | |
} | |
this.config = { | |
column: 'productName', | |
placeholder: 'Add filter...', | |
getSuggestions: fetchSimulatedSuggestions, | |
onChipsChanged: (chips) => { /* console.log("Chips changed:", chips); */ }, | |
...config | |
}; | |
this.instanceId = 'chips-filter-' + Date.now() + Math.random().toString(36).substring(2, 7); | |
this.chips = []; // Array of string values | |
this.currentInputValue = ""; | |
this.candidateSuggestions = []; // Full list of unique, processed suggestions (up to 20 + literal) | |
this.highlightedSuggestionIndex = -1; | |
this.dropdownVisible = false; | |
this.isMouseDownOnDropdown = false; | |
this.debouncedFetch = this.debounce(this._fetchAndFormatSuggestions.bind(this), 250); // Debounce API calls | |
this._setupDOM(); | |
this._bindEvents(); | |
this.config.onChipsChanged(this.chips); // Notify initial state (empty chips) | |
} | |
_setupDOM() { | |
this.targetElement.innerHTML = ` | |
<div class="chips-input-container" id="${this.instanceId}-combobox" role="combobox" aria-haspopup="listbox" aria-owns="${this.instanceId}-dropdown" aria-expanded="false"> | |
<span class="chips-wrapper"></span> | |
<input type="text" class="text-input" aria-autocomplete="list" aria-controls="${this.instanceId}-dropdown" aria-labelledby="${this.instanceId}-label"> | |
<button type="button" class="copy-chips-btn" title="Copy all chips"> | |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"> | |
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> | |
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> | |
</svg> | |
</button> | |
</div> | |
<ul class="suggestions-dropdown" id="${this.instanceId}-dropdown" role="listbox" style="display: none;"></ul> | |
<span id="${this.instanceId}-label" style="display:none;">Filter for ${this.config.column}</span> | |
`; | |
this.elements = { | |
outerContainer: this.targetElement, // The one chips-filter-component is on | |
inputContainer: this.targetElement.querySelector('.chips-input-container'), | |
chipsWrapper: this.targetElement.querySelector('.chips-wrapper'), | |
input: this.targetElement.querySelector('.text-input'), | |
copyBtn: this.targetElement.querySelector('.copy-chips-btn'), | |
dropdown: this.targetElement.querySelector('.suggestions-dropdown'), | |
ariaLabel: this.targetElement.querySelector(`#${this.instanceId}-label`) | |
}; | |
this.updatePlaceholderAndAria(); | |
} | |
updatePlaceholderAndAria() { | |
const isStringColumn = columnTypes[this.config.column] === 'string'; | |
if (isStringColumn) { | |
this.elements.input.placeholder = this.config.placeholder; | |
} else { | |
this.elements.input.placeholder = `Exact match for ${this.config.column} (numeric)`; | |
} | |
this.elements.ariaLabel.textContent = `Filter for ${this.config.column}${isStringColumn ? ', type for suggestions' : ''}`; | |
} | |
_bindEvents() { | |
this.elements.inputContainer.addEventListener('click', (e) => { | |
// Focus input if click is not on chip remove button or copy button | |
if (!e.target.closest('.chip-remove') && !e.target.closest('.copy-chips-btn')) { | |
this.elements.input.focus(); | |
} | |
}); | |
this.elements.input.addEventListener('focus', this._handleInputFocus.bind(this)); | |
this.elements.input.addEventListener('input', this._handleInput.bind(this)); | |
this.elements.input.addEventListener('keydown', this._handleKeyDown.bind(this)); | |
this.elements.input.addEventListener('blur', this._handleInputBlur.bind(this)); | |
this.elements.input.addEventListener('paste', this._handlePaste.bind(this)); | |
this.elements.copyBtn.addEventListener('click', this._copyChipsToClipboard.bind(this)); | |
this.elements.dropdown.addEventListener('mousedown', (e) => { | |
const listItem = e.target.closest('li'); | |
if (listItem) this.isMouseDownOnDropdown = true; | |
}); | |
this.elements.dropdown.addEventListener('click', (e) => { | |
const listItem = e.target.closest('li'); | |
if (listItem && typeof listItem.dataset.value !== "undefined") { // dataset.value could be empty string | |
this._selectSuggestionByValue(listItem.dataset.value); | |
} | |
this.isMouseDownOnDropdown = false; | |
}); | |
this.elements.dropdown.addEventListener('mousemove', (e) => { // For mouse hover highlighting | |
const listItem = e.target.closest('li'); | |
if (listItem) { | |
const allItems = Array.from(this.elements.dropdown.querySelectorAll('li')); | |
const index = allItems.indexOf(listItem); | |
if (index !== -1 && index !== this.highlightedSuggestionIndex) { | |
this._highlightSuggestion(index); | |
} | |
} | |
}); | |
} | |
_handleInputFocus() { | |
this.currentInputValue = this.elements.input.value; // Sync | |
if (this.currentInputValue.trim() !== "" && columnTypes[this.config.column] === 'string') { | |
this.debouncedFetch(); // Pass no args, uses this.currentInputValue | |
} else if (columnTypes[this.config.column] === 'string') { | |
// Optionally show some initial suggestions or an empty state. | |
// For now, do nothing until user types. | |
this._renderSuggestions([]); // Clear any old suggestions | |
} | |
} | |
_handleInput(e) { | |
this.currentInputValue = e.target.value; | |
if (columnTypes[this.config.column] === 'string') { | |
if (this.currentInputValue.trim() === "") { | |
this._hideDropdown(); // Hide if input cleared | |
this.candidateSuggestions = []; // Clear candidates | |
return; | |
} | |
this.debouncedFetch(); | |
} else { | |
this._hideDropdown(); // No suggestions for non-string types | |
} | |
} | |
_handleKeyDown(e) { | |
switch (e.key) { | |
case 'ArrowDown': | |
if (this.dropdownVisible && this.candidateSuggestions.length > 0) { | |
e.preventDefault(); this._navigateDropdown(1); | |
} else if (columnTypes[this.config.column] === 'string' && this.currentInputValue.trim() !== "" && this.candidateSuggestions.length === 0){ | |
// If dropdown not visible but could be, trigger fetch | |
this.debouncedFetch.flush(); // Flush any pending debounce | |
} | |
break; | |
case 'ArrowUp': | |
if (this.dropdownVisible && this.candidateSuggestions.length > 0) { | |
e.preventDefault(); this._navigateDropdown(-1); | |
} | |
break; | |
case 'Enter': | |
e.preventDefault(); // Always prevent form submission or other default | |
if (this.dropdownVisible && this.highlightedSuggestionIndex !== -1) { | |
this._selectHighlightedSuggestion(); | |
} else if (this.currentInputValue.trim() !== "") { // Add current text as chip | |
this._addChip(this.currentInputValue.trim()); | |
this._clearInputAndHideDropdown(); | |
} | |
break; | |
case 'Escape': | |
if (this.dropdownVisible) { | |
e.preventDefault(); this._hideDropdown(); | |
} | |
break; | |
case 'Backspace': | |
if (this.currentInputValue === '' && this.chips.length > 0) { | |
e.preventDefault(); | |
this._removeChip(this.chips[this.chips.length - 1]); // Remove last chip | |
} | |
break; | |
default: // If typing any other character, ensure mouse down flag is false | |
this.isMouseDownOnDropdown = false; | |
} | |
} | |
_handleInputBlur() { | |
setTimeout(() => { // Delay to allow click on dropdown item to register before blur hides it | |
if (!this.isMouseDownOnDropdown) { | |
this._hideDropdown(); | |
} | |
}, 150); | |
} | |
async _fetchAndFormatSuggestions() { | |
const trimmedInput = String(this.currentInputValue).trim(); | |
if (columnTypes[this.config.column] !== 'string' || trimmedInput === "") { | |
this._renderSuggestions([]); // Clears dropdown | |
return; | |
} | |
const rawApiResults = await this.config.getSuggestions(trimmedInput, this.config.column); | |
let finalDisplayItems = []; | |
// Ensure typed literal is an option. Prepend with "Add " for clarity if it's the literal. | |
const literalOptionValue = trimmedInput; | |
const literalDisplayText = `Add "${trimmedInput}"`; // This is what user sees in dropdown | |
// Filter out API results that are identical to the literal typed value to avoid functional duplicates. | |
const uniqueApiSuggestions = [...new Set(rawApiResults.map(s => String(s)))] | |
.filter(s => s.toLowerCase() !== literalOptionValue.toLowerCase()); | |
// "The exact literal value that is typed is also offered as the second choice" | |
// Logic: Literal choice, then API. If API's best is different, it's put first. | |
if (uniqueApiSuggestions.length > 0) { | |
// API has suggestions different from the literal | |
finalDisplayItems.push({value: literalOptionValue, display: literalDisplayText, isLiteral: true}); | |
uniqueApiSuggestions.forEach(s => finalDisplayItems.push({value: s, display: s, isLiteral: false})); | |
} else { | |
// Only literal option or API returned same as literal / empty | |
finalDisplayItems.push({value: literalOptionValue, display: literalDisplayText, isLiteral: true}); | |
} | |
// Re-evaluate "second choice" based on the final items for display | |
// If first API suggestion is strong and *different* from literal, it might go first. | |
// Simpler interpretation: 'literal as option' should be prominent. Top or second. | |
// My current finalDisplayItems puts LiteralOptionValue (via "Add '...'") as first. | |
if (rawApiResults.length > 0 && String(rawApiResults[0]).toLowerCase() !== literalOptionValue.toLowerCase()) { | |
// If API has a top suggestion different from literal, place it first. | |
finalDisplayItems = [ | |
{value: rawApiResults[0], display: rawApiResults[0], isLiteral: false}, // API Top | |
{value: literalOptionValue, display: literalDisplayText, isLiteral: true}, // Literal value as second | |
...uniqueApiSuggestions.filter(s => s.toLowerCase() !== String(rawApiResults[0]).toLowerCase()) | |
.map(s => ({value: s, display: s, isLiteral: false})) // Rest of unique API suggestions | |
]; | |
} else { | |
// Literal value is effectively first or there are no conflicting top API suggestions. | |
// Structure is: Literal, then unique API suggestions. | |
finalDisplayItems = [ | |
{value: literalOptionValue, display: literalDisplayText, isLiteral: true}, | |
...uniqueApiSuggestions.map(s => ({value: s, display: s, isLiteral: false})) | |
]; | |
} | |
this.candidateSuggestions = finalDisplayItems.slice(0, 20 + 1); // Allow for up to 20 API + 1 literal | |
this._renderSuggestions(this.candidateSuggestions); | |
} | |
_renderSuggestions(suggestionObjects) { // Expects array of {value, display, isLiteral} | |
this.elements.dropdown.innerHTML = ''; | |
this.highlightedSuggestionIndex = -1; | |
if (suggestionObjects.length === 0) { | |
this._hideDropdown(); | |
return; | |
} | |
suggestionObjects.forEach((suggObj, index) => { | |
const li = document.createElement('li'); | |
// If it's the literal suggestion "Add '...'", display that, otherwise the value. | |
li.innerHTML = suggObj.isLiteral ? `Add "<strong>${suggObj.value}</strong>"` : suggObj.display; | |
li.dataset.value = suggObj.value; // The actual value to use for the chip | |
li.setAttribute('role', 'option'); | |
li.id = `${this.instanceId}-option-${index}`; | |
if (suggObj.isLiteral) { | |
li.classList.add('is-literal'); | |
} | |
this.elements.dropdown.appendChild(li); | |
}); | |
this.elements.input.removeAttribute('aria-activedescendant'); | |
this._showDropdown(); | |
if (this.currentInputValue.trim() !== "" && suggestionObjects.length > 0) { | |
this._highlightSuggestion(0); // Auto-highlight first item | |
} | |
} | |
_highlightSuggestion(index) { | |
const items = this.elements.dropdown.querySelectorAll('li'); | |
if (this.highlightedSuggestionIndex >= 0 && items[this.highlightedSuggestionIndex]) { | |
items[this.highlightedSuggestionIndex].classList.remove('highlighted'); | |
} | |
if (index >= 0 && index < items.length) { | |
this.highlightedSuggestionIndex = index; | |
items[this.highlightedSuggestionIndex].classList.add('highlighted'); | |
items[this.highlightedSuggestionIndex].scrollIntoView({ block: 'nearest', inline: 'nearest' }); | |
this.elements.input.setAttribute('aria-activedescendant', items[this.highlightedSuggestionIndex].id); | |
} else { // Index out of bounds or reset | |
this.highlightedSuggestionIndex = -1; | |
this.elements.input.removeAttribute('aria-activedescendant'); | |
} | |
} | |
_navigateDropdown(direction) { | |
if (!this.dropdownVisible || this.candidateSuggestions.length === 0) return; | |
const itemsCount = this.candidateSuggestions.length; | |
let newIndex = this.highlightedSuggestionIndex + direction; | |
if (newIndex < 0) newIndex = itemsCount - 1; | |
if (newIndex >= itemsCount) newIndex = 0; | |
this._highlightSuggestion(newIndex); | |
} | |
_selectHighlightedSuggestion() { | |
if (this.highlightedSuggestionIndex !== -1 && this.candidateSuggestions[this.highlightedSuggestionIndex]) { | |
const selectedValue = this.candidateSuggestions[this.highlightedSuggestionIndex].value; // Use .value field | |
this._addChip(selectedValue); | |
this._clearInputAndHideDropdown(); | |
} | |
} | |
_selectSuggestionByValue(value) { // Value comes from li.dataset.value | |
this._addChip(value); | |
this._clearInputAndHideDropdown(); | |
} | |
_addChip(value) { | |
const chipValue = String(value).trim(); | |
if (chipValue === '' || this.chips.some(c => c.toLowerCase() === chipValue.toLowerCase())) { | |
if (this.chips.some(c => c.toLowerCase() === chipValue.toLowerCase())) { | |
console.warn(`Chip "${chipValue}" (case-insensitive) already exists.`); | |
} | |
// Don't add duplicate or empty, but still clear input as user intent was to "add" | |
this._clearInputAndHideDropdown(); | |
return; | |
} | |
this.chips.push(chipValue); | |
this._renderChips(); | |
this.config.onChipsChanged(this.chips); | |
} | |
_removeChip(valueToRemove) { | |
this.chips = this.chips.filter(chip => chip !== valueToRemove); | |
this._renderChips(); | |
this.config.onChipsChanged(this.chips); | |
this.elements.input.focus(); | |
} | |
_renderChips() { | |
this.elements.chipsWrapper.innerHTML = ''; | |
this.chips.forEach(chipValue => { | |
const chipElement = document.createElement('span'); | |
chipElement.className = 'chip'; | |
chipElement.textContent = chipValue; | |
const removeBtn = document.createElement('button'); | |
removeBtn.type = 'button'; // Important for forms | |
removeBtn.className = 'chip-remove'; | |
removeBtn.innerHTML = '×'; | |
removeBtn.setAttribute('aria-label', `Remove ${chipValue}`); | |
removeBtn.onclick = (e) => { e.stopPropagation(); this._removeChip(chipValue);}; | |
chipElement.appendChild(removeBtn); | |
this.elements.chipsWrapper.appendChild(chipElement); | |
}); | |
// Update ARIA live region or main label about current chips if needed | |
// For now, main interaction through input, focus on input after changes. | |
} | |
_clearInputAndHideDropdown() { | |
this.elements.input.value = ''; | |
this.currentInputValue = ''; | |
this.candidateSuggestions = []; | |
this._hideDropdown(); | |
this.elements.input.focus(); | |
} | |
_showDropdown() { | |
if (this.elements.dropdown.children.length > 0) { | |
this.elements.dropdown.style.display = 'block'; | |
this.elements.inputContainer.setAttribute('aria-expanded', 'true'); | |
this.dropdownVisible = true; | |
// Simple positioning below input container | |
// Ensure parent (.chips-filter-component) has position:relative for this to work as intended. | |
const inputRect = this.elements.inputContainer.getBoundingClientRect(); | |
this.elements.dropdown.style.left = '0'; // Aligned with parent left | |
this.elements.dropdown.style.top = inputRect.height + 'px'; // Positioned below the input container | |
this.elements.dropdown.style.width = inputRect.width + 'px'; // Match width of input container | |
} else { | |
this._hideDropdown(); | |
} | |
} | |
_hideDropdown() { | |
this.elements.dropdown.style.display = 'none'; | |
this.elements.inputContainer.setAttribute('aria-expanded', 'false'); | |
if (this.elements.input) this.elements.input.removeAttribute('aria-activedescendant'); | |
this.highlightedSuggestionIndex = -1; | |
this.dropdownVisible = false; | |
// Do not clear candidateSuggestions here as it might be needed if user focuses out then back in quickly | |
} | |
_copyChipsToClipboard() { | |
const chipsString = this.chips.join(','); | |
navigator.clipboard.writeText(chipsString) | |
.then(() => { | |
console.log('Chips copied to clipboard:', chipsString); | |
// Optional: provide user feedback e.g. tooltip "Copied!" | |
}) | |
.catch(err => console.error('Failed to copy chips:', err)); | |
this.elements.input.focus(); | |
} | |
_handlePaste(event) { | |
event.preventDefault(); | |
const pasteData = (event.clipboardData || window.clipboardData).getData('text'); | |
if (event.shiftKey) { | |
this._addChip(pasteData); // Add entire content as one chip | |
} else { | |
const pastedChips = pasteData.split(',') | |
.map(s => s.trim()) | |
.filter(s => s !== ''); // Filter out empty strings resulting from multiple commas | |
pastedChips.forEach(chipValue => this._addChip(chipValue)); | |
} | |
// After adding chips, current input text in input field may or may not be relevant | |
// Choices.js clears it. Let's clear it for consistency. | |
this.elements.input.value = ''; | |
this.currentInputValue = ''; | |
this._hideDropdown(); // Hide any open dropdown. | |
} | |
setColumn(newColumn) { | |
if (columnTypes[newColumn]) { | |
this.config.column = newColumn; | |
this.chips = []; | |
this._renderChips(); | |
this._clearInputAndHideDropdown(); | |
apiCache.clear(); // Clear cache for new column context | |
this.updatePlaceholderAndAria(); | |
this.config.onChipsChanged(this.chips); | |
console.log(`Filter column changed to: ${newColumn}`); | |
} else { | |
console.error(`Invalid column for filter: ${newColumn}`); | |
} | |
} | |
getChipValues() { return [...this.chips]; } | |
debounce(func, delay) { | |
let timeoutId; | |
const debounced = function(...args) { | |
const context = this; | |
clearTimeout(timeoutId); | |
timeoutId = setTimeout(() => { | |
func.apply(context, args); | |
timeoutId = null; // Clear after execution | |
}, delay); | |
}; | |
// Add a flush method to immediately execute | |
debounced.flush = () => { | |
if (timeoutId) { | |
clearTimeout(timeoutId); | |
// Note: Cannot pass original args here easily unless stored. | |
// For this specific use (flushing debouncedFetch), it uses `this.currentInputValue` | |
// so direct call is okay. | |
func.call(this); | |
timeoutId = null; | |
} | |
}; | |
return debounced; | |
} | |
} | |
// --- Demo Initialization --- | |
document.addEventListener('DOMContentLoaded', () => { | |
const chipsOutputElement = document.getElementById('currentChipsOutput'); | |
const chipsInstance = new ChipsFilter('#chipsFilterComponentContainer', { | |
placeholder: 'Filter products...', // Default for 'productName' | |
column: 'productName', | |
onChipsChanged: (chips) => { | |
chipsOutputElement.textContent = chips.length > 0 ? JSON.stringify(chips, null, 2) : "[]"; | |
} | |
}); | |
const columnSelect = document.getElementById('columnSelect'); | |
columnSelect.addEventListener('change', (e) => { | |
chipsInstance.setColumn(e.target.value); | |
}); | |
// For easier debugging from console: | |
window.dev = { chipsInstance, apiCache, allSampleData }; | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment