Skip to content

Instantly share code, notes, and snippets.

@marlonnardi
Last active November 6, 2025 13:35
Show Gist options
  • Select an option

  • Save marlonnardi/39a76f39abc2ae37abe817e253bbf196 to your computer and use it in GitHub Desktop.

Select an option

Save marlonnardi/39a76f39abc2ae37abe817e253bbf196 to your computer and use it in GitHub Desktop.
Custom script for TUniCheckComboBox: search box, select/deselect all, item counter, and auto-close picker when clicking outside.

✅ Full Changelog – Custom TUniCheckComboBox Script

🟢 1. Initial Version – Custom Picker Header

Added a custom header inside the ComboBox picker containing:

  • Search input field (<input type="text">)
  • Clear “×” button for search text
  • Select All and Deselect All buttons

Additional features:

  • Added full Portuguese (PT-BR) localization:
    • "Todos", "Nenhum", "Pesquisar..."
  • Implemented maximum picker height using MAX_PICKER_HEIGHT

🟡 2. Added Dynamic Item Counter

  • Display of selected items in the Select All button.
  • Supports:
    • (selected/total) format, or
    • (selected) only
  • Counter automatically updates after selecting/unselecting items.

🟡 3. Real-Time Search / Filtering

  • Typing in the search box hides non-matching items immediately.
  • Clear “×” button appears only when text is entered.
  • Picker height is recalculated according to the number of visible items.

🟡 4. Bulk Selection for Visible Items Only

  • Select All selects only visible (filtered) items.
  • Deselect All deselects only visible items.
  • Uses me.setValue() to keep existing selections intact.

🟡 5. Scroll Position Preservation

  • Scroll position is preserved when resizing or filtering.
  • Prevents picker from jumping back to the top.

🟡 6. Intelligent Height Management

  • Picker height now considers:
    • Header height
    • Visible list height
    • MAX_PICKER_HEIGHT
  • Uses .setHeight() together with dynamic CSS on .x-boundlist-list-ct.

🟡 7. Value Change Handling

  • On any change in selected items:
    • Counter is refreshed
    • Search box regains focus if picker is open

🟡 8. Reset on Collapse

  • When combo is collapsed:
    • Clears search text
    • Hides clear button
    • Restores all hidden list items
    • Ensures picker reopens with the full list

🟡 9. DOM Cleanup on Destroy

  • Removes dynamically created DOM elements (header, input, buttons)
  • Unbinds events to avoid memory leaks

🔵 10. 🆕 Auto-Close Picker on Outside Click

Problem solved: Picker only closed by clicking on the component itself.

New behavior:

  • When combo expands:
    • Adds global listeners (mousedown, touchstart) to document
    • If click is outside both combobox field and picker → collapse()
  • On collapse or destroy:
    • These listeners are removed automatically
  • Uses Ext.defer() to avoid closing on the same click that opened the picker

✅ Final Result – Features Implemented

✔ Custom header inside picker
✔ Real-time search + clear button
✔ Select/Deselect visible items only
✔ Dynamic selected item counter
✔ Scroll preservation
✔ Adaptive height control
✔ Reset behavior on collapse
✔ Safe DOM cleanup
Auto-close when clicking outside (new)
Adaptive width control (new)


chrome-capture-2025-11-06

function afterrender(sender, eOpts) {
var me = sender;
// === Labels (PT-BR text) ===
var LABEL_SELECT_ALL = 'Todos';
var LABEL_DESELECT_ALL = 'Nenhum';
var LABEL_SEARCH = 'Pesquisar...';
// === Picker limits ===
var MAX_PICKER_HEIGHT = 300; // px
var MIN_PICKER_WIDTH = 300; // px (new)
if (me._customHeaderAdded) {
return;
}
me._customHeaderAdded = true;
var showCount = 1; // 1 or 0 -> show selected count
var showTotal = 1; // 1 or 0 -> show total count
var _insertEditField = function () {
var picker = me.getPicker && me.getPicker();
if (!picker || picker.destroyed || !picker.id || !picker.el) return;
var _id = picker.id;
me._customPickerId = _id;
// --- Enforce minimum picker width (NEW) -------------------------------
var enforcePickerMinWidth = function () {
try {
var fieldW = (me.getWidth && me.getWidth()) || 0;
if (picker && picker.setWidth && fieldW > 0 && fieldW < MIN_PICKER_WIDTH) {
picker.setWidth(MIN_PICKER_WIDTH);
picker.minWidth = MIN_PICKER_WIDTH;
if (picker.updateLayout) picker.updateLayout();
}
} catch (e) { /* silent */ }
};
enforcePickerMinWidth();
if (picker.on) picker.on('show', enforcePickerMinWidth);
if (me.on) me.on('resize', enforcePickerMinWidth);
// ---------------------------------------------------------------------
// --- Determine if an item is selected (define BEFORE using it) -------
var isItemSelected = function (item) {
if (!item) return false;
var checkbox = item.querySelector && item.querySelector('input[type="checkbox"]');
if (checkbox) return !!checkbox.checked;
if (item.classList.contains('x-boundlist-selected') ||
item.classList.contains('x-checkbox-checked') ||
item.getAttribute('aria-selected') === 'true' ||
item.getAttribute('aria-checked') === 'true') {
return true;
}
var styles = window.getComputedStyle(item);
if (styles.backgroundColor &&
styles.backgroundColor !== 'transparent' &&
styles.backgroundColor !== 'rgba(0, 0, 0, 0)') {
return true;
}
return false;
};
// ---------------------------------------------------------------------
var getSelectedCount = function () {
var count = 0;
var allItems = picker.el.query('.x-boundlist-item');
for (var i = 0; i < allItems.length; i++) {
if (isItemSelected(allItems[i])) count++;
}
return count;
};
var getTotalCount = function () {
return picker.el.query('.x-boundlist-item').length;
};
var updateSelectAllButtonText = function () {
var selectAllButton = Ext.get(_id + '_selectAllButton');
if (!selectAllButton || !selectAllButton.dom) return;
if (showCount === 1) {
var selectedCount = getSelectedCount();
if (showTotal === 1) {
var totalCount = getTotalCount();
selectAllButton.dom.innerHTML = LABEL_SELECT_ALL + '(' + selectedCount + '/' + totalCount + ')';
} else {
selectAllButton.dom.innerHTML = LABEL_SELECT_ALL + '(' + selectedCount + ')';
}
} else {
selectAllButton.dom.innerHTML = LABEL_SELECT_ALL;
}
};
var selectAllFlex = (showCount === 1) ? '2' : '1';
var deselectAllFlex = '1';
// --- Build custom header (search + buttons) at the top of the picker ---
var headerContainer = Ext.DomHelper.insertFirst(_id, {
tag: 'div',
cls: 'custom-header-container',
style: 'border-bottom: 1px solid #ddd; background: white;',
children: [{
tag: 'div',
style: 'padding: 5px;',
children: [{
tag: 'div',
style: 'position: relative; margin-bottom: 5px;',
children: [{
tag: 'input',
type: 'text',
id: _id + '_filterEdit',
placeholder: LABEL_SEARCH,
style: 'width: 100%; padding: 4px; padding-right: 25px; box-sizing: border-box;'
}, {
tag: 'div',
html: '×',
id: _id + '_clearButton',
style: 'position: absolute; right: 10px; top: 50%; transform: translateY(-50%); cursor: pointer; color: #999; font-size: 16px; font-weight: bold; display: none;'
}]
}, {
tag: 'div',
style: 'display: flex; justify-content: space-between; gap: 4px;',
children: [{
tag: 'button',
type: 'button',
id: _id + '_selectAllButton',
html: (showCount === 1)
? ((showTotal === 1) ? LABEL_SELECT_ALL + '(0/0)' : LABEL_SELECT_ALL + '(0)')
: LABEL_SELECT_ALL,
style: 'flex: ' + selectAllFlex + '; min-width: 0; padding: 4px 2px; background: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;'
}, {
tag: 'button',
type: 'button',
id: _id + '_deselectAllButton',
html: LABEL_DESELECT_ALL,
style: 'flex: ' + deselectAllFlex + '; min-width: 0; padding: 4px 2px; background: #f44336; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;'
}]
}]
}]
});
// ----------------------------------------------------------------------
var filterField = Ext.get(_id + '_filterEdit');
var clearButton = Ext.get(_id + '_clearButton');
var selectAllButton = Ext.get(_id + '_selectAllButton');
var deselectAllButton = Ext.get(_id + '_deselectAllButton');
me._customComponents = {
filterField: filterField,
clearButton: clearButton,
selectAllButton: selectAllButton,
deselectAllButton: deselectAllButton,
headerContainer: headerContainer
};
// header height may not be accurate immediately; read after a tick
var headerHeight = headerContainer && headerContainer.offsetHeight || 0;
// --- Adjust picker height based on content and header -------------------
var adjustPickerHeight = function () {
setTimeout(function () {
var p = me.getPicker && me.getPicker();
if (!p || !p.el) return;
var listEl = p.el.down('.x-boundlist-list-ct');
if (!listEl) return;
var currentScrollTop = listEl.getScrollTop();
listEl.setStyle({
'max-height': (MAX_PICKER_HEIGHT - headerHeight) + 'px',
'height': 'auto',
'overflow-y': 'auto'
});
var contentHeight = listEl.getHeight();
var calculatedHeight = Math.min(contentHeight + headerHeight, MAX_PICKER_HEIGHT);
if (p.setHeight) p.setHeight(calculatedHeight);
if (currentScrollTop > 0) listEl.setScrollTop(currentScrollTop);
}, 50);
};
// ----------------------------------------------------------------------
// --- Clear search and restore all items --------------------------------
var clearFilter = function () {
if (filterField && filterField.dom) {
filterField.dom.value = '';
filterField.focus();
}
if (clearButton) clearButton.hide();
var items = picker.el.query('.x-boundlist-item');
for (var i = 0; i < items.length; i++) {
items[i].style.display = '';
}
adjustPickerHeight();
};
// ----------------------------------------------------------------------
// --- Bulk selection/deselection for visible items only -----------------
var bulkSelection = function (select) {
var visibleItems = picker.el.query('.x-boundlist-item:not([style*="display: none"])');
var store = me.getStore && me.getStore();
if (!store) return;
var values = me.getValue && (me.getValue() || []) || [];
var newValues = select ? [] : values.slice();
if (select) {
for (var i = 0; i < visibleItems.length; i++) {
var item = visibleItems[i];
var recordIndex = item.getAttribute('data-recordIndex') ||
Array.prototype.indexOf.call(item.parentNode.children, item);
var record = store.getAt(recordIndex);
if (record) {
var value = record.get(me.valueField || me.displayField);
if (newValues.indexOf(value) === -1) newValues.push(value);
}
}
} else {
for (var j = 0; j < visibleItems.length; j++) {
var it = visibleItems[j];
var idx = it.getAttribute('data-recordIndex') ||
Array.prototype.indexOf.call(it.parentNode.children, it);
var rec = store.getAt(idx);
if (rec) {
var v = rec.get(me.valueField || me.displayField);
var vi = newValues.indexOf(v);
if (vi !== -1) newValues.splice(vi, 1);
}
}
}
if (me.setValue) me.setValue(newValues);
setTimeout(function () {
updateSelectAllButtonText();
if (filterField && filterField.dom) filterField.focus();
}, 50);
};
var selectAllVisible = function () { bulkSelection(true); };
var deselectAllVisible = function () { bulkSelection(false); };
// ----------------------------------------------------------------------
var handleClearButtonClick = function () {
setTimeout(updateSelectAllButtonText, 10);
};
// --- Bind ExtJS clear triggers to keep counter in sync -----------------
var findAndBindClearButton = function () {
var pickerEl = picker.el;
if (!pickerEl) return;
var clearButtons = pickerEl.query('.x-form-clear-trigger');
if (clearButtons.length > 0) {
for (var i = 0; i < clearButtons.length; i++) {
var clearBtn = clearButtons[i];
if (!clearBtn._customClearHandlerAdded) {
Ext.get(clearBtn).on('click', handleClearButtonClick);
clearBtn._customClearHandlerAdded = true;
}
}
}
};
// ----------------------------------------------------------------------
if (clearButton) clearButton.on('click', clearFilter);
if (selectAllButton) selectAllButton.on('click', selectAllVisible);
if (deselectAllButton) deselectAllButton.on('click', deselectAllVisible);
me.on && me.on('change', function () { setTimeout(updateSelectAllButtonText, 10); });
// --- Live filtering on keyup -------------------------------------------
if (filterField) {
filterField.on('keyup', function () {
var searchText = (filterField.dom.value || '').toLowerCase();
if (searchText.length > 0) {
clearButton && clearButton.show();
} else {
clearButton && clearButton.hide();
}
var items = picker.el.query('.x-boundlist-item');
for (var i = 0; i < items.length; i++) {
var text = items[i].textContent || items[i].innerText;
items[i].style.display = (text.toLowerCase().indexOf(searchText) !== -1) ? '' : 'none';
}
adjustPickerHeight();
});
}
// ----------------------------------------------------------------------
// --- Keep counter synced when clicking inside the list -----------------
if (picker.el) {
picker.el.on('click', function (event) {
var target = event.target;
var item = target.closest && target.closest('.x-boundlist-item');
if (item) setTimeout(updateSelectAllButtonText, 10);
if (target.classList && target.classList.contains('x-form-clear-trigger')) {
handleClearButtonClick();
}
});
}
// ----------------------------------------------------------------------
// ====== Auto-close picker when clicking outside (BIND/UNBIND) =========
var bindOutsideCloser = function () {
Ext.defer(function () {
me._outsideCloser = function (e) {
var p = me.getPicker && me.getPicker();
if (!p || p.destroyed || !p.el) return;
var t = e.target;
var clickInsidePicker = p.el.contains(t);
var clickInsideField = me.el && me.el.contains(t);
if (!clickInsidePicker && !clickInsideField) {
me.collapse && me.collapse();
}
};
Ext.getDoc().on('mousedown', me._outsideCloser);
Ext.getDoc().on('touchstart', me._outsideCloser);
}, 50);
};
var unbindOutsideCloser = function () {
if (me._outsideCloser) {
Ext.getDoc().un('mousedown', me._outsideCloser);
Ext.getDoc().un('touchstart', me._outsideCloser);
me._outsideCloser = null;
}
};
// ====== /Auto-close picker when clicking outside ======================
me.on && me.on('expand', function () {
setTimeout(function () {
updateSelectAllButtonText();
findAndBindClearButton();
if (filterField && filterField.dom) filterField.focus();
adjustPickerHeight();
enforcePickerMinWidth(); // ensure min width after showing
}, 100);
bindOutsideCloser();
});
me.on && me.on('collapse', function () {
unbindOutsideCloser();
if (filterField && filterField.dom) filterField.dom.value = '';
clearButton && clearButton.hide();
var items = picker.el.query('.x-boundlist-item');
for (var i = 0; i < items.length; i++) items[i].style.display = '';
});
setTimeout(findAndBindClearButton, 200);
adjustPickerHeight();
me.on && me.on('expand', adjustPickerHeight);
picker.un && picker.un('show', _insertEditField);
// keep a reference for destroy cleanup
me._unbindOutsideCloserFn = unbindOutsideCloser;
};
var p = me.getPicker && me.getPicker();
if (p && p.on) p.on('show', _insertEditField);
me.on && me.on('destroy', function () {
// ensure global listeners are removed
if (me._unbindOutsideCloserFn) {
me._unbindOutsideCloserFn();
me._unbindOutsideCloserFn = null;
}
if (me._customComponents) {
try {
if (me._customComponents.headerContainer) {
var hc = me._customComponents.headerContainer;
if (hc.dom && hc.remove) hc.remove();
else if (hc.parentNode) hc.parentNode.removeChild(hc);
}
} catch (e) {
if (window.console) console.log('Error during cleanup:', e);
}
me._customComponents = null;
}
me._customHeaderAdded = false;
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment