Skip to content

Instantly share code, notes, and snippets.

@secemp9
Created May 20, 2025 01:16
Show Gist options
  • Save secemp9/792b8eb0d2540d5eb2912b5bc1e73e07 to your computer and use it in GitHub Desktop.
Save secemp9/792b8eb0d2540d5eb2912b5bc1e73e07 to your computer and use it in GitHub Desktop.
<!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 = '&times;';
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