SearchableComboBox enhancement to selectively disable items from filter

10 views
Skip to first unread message

Mark Schmieder

unread,
Apr 1, 2023, 1:24:59 AM4/1/23
to ControlsFX
It is common in many applications to use list category labels within longer lists (in some cases, splitting to a series of selection criteria combo boxes would be too cumbersome for the user), and custom code is trivial to write for greying these items as non-selectable.

Unfortunately createFilteredComboBox() in SearchableComboBoxSkin is private, so I had to clone the class and modify it for local use. 

My enhancement is non-ideal in order to be low in impact to ControlsFX re-releases, as it hard-wired the prefix tag used to identify list items that are "markers" for sub-lists or categories and which should not be included in filtered lists or allowed to be selectable.

Given this caveat, I have a public class-level constant in SearchableComboBoxSkin:

/**

* List Category Labels are greyed out, non-selectable list items that help

* organize the list into categories for easier viewing. This constant

* establishes a standard for how to uniquely mark them as such.

* <p>

* TODO: Move this to a ControlsFX Utility class for general use.

*/

public static final String LIST_CATEGORY_LABEL_PREFIX = "------";


Then, after box.setFocusTraversed( false ) in createFilteredComboBox():

// TODO: Find a way to do this without so much class casting.

box.setCellFactory( param -> {

final ListCell< T > cell = new ListCell< T >() {

@Override

public void updateItem( final T item, final boolean empty ) {

super.updateItem( item, empty );


if ( ( item != null ) && ( item instanceof String ) ) {

final String itemText = ( String ) item;

setText( itemText );


// Selectively enable or disable each item based on

// whether it is a List Category Label.

final boolean disable = itemText.startsWith( LIST_CATEGORY_LABEL_PREFIX );

setDisable( disable );

if ( disable ) {

setTextFill( Color.DARKGRAY );

}

else {

setTextFill( Color.BLACK );

}

}

else {

setText( null );

}

}

};


return cell;

} );


Obviously, it's better to provide API for setting the marker string that is used for detecting non-selectable list items from the filter.

There is likely a way to generalize the concept further, but I definitely found that a single character is not enough (for instance, a single hyphen may appear in a normal selectable list item).

Mark Schmieder

unread,
Apr 1, 2023, 2:10:41 AM4/1/23
to ControlsFX
I missed one extra code change in SearchableComboBoxSkin in my original post above:

/**

* Return the Predicate to filter the pop-up items based on the given search

* text.

*/

@SuppressWarnings("nls")

private Predicate< T > predicate( final String searchText ) {

// OK, if the display text contains all words, ignoring case, and also

// skipping any category labels as they would be confusing in the

// context of filtered lists (and also in some cases would result in

// false hits vs. empty filters that revert back to the full list).

final String[] lowerCaseSearchWords = searchText.toLowerCase().split( " " );

return value -> {

final String displayText = getDisplayText( value );

final String lowerCaseDisplayText = displayText.toLowerCase();

return Arrays.stream( lowerCaseSearchWords )

.allMatch( word -> !lowerCaseDisplayText

.startsWith( LIST_CATEGORY_LABEL_PREFIX )

&& lowerCaseDisplayText.contains( word ) );

};

}



Mark Schmieder

unread,
Apr 1, 2023, 2:20:45 AM4/1/23
to ControlsFX
One final addition, now that I have finished comparing to what is in ControlsFX GitHub, as I had to make a large number of edits to checkApplyAndCancel() in SearchableComboBoxSkin to support my filter exclusion criteria for non-selectable list items:

/**

* Used to alter the behaviour. React on ENTER, TAB and ESC.

*/

private void checkApplyAndCancel( final KeyEvent keyEvent ) {

final ComboBox< T > comboBox = getSkinnable();

final ObservableList< T > filteredItems = filteredComboBox.getItems();


// If we are dealing with text, post-filter the list for items that

// serve as List Category Labels, to avoid invalid selections.

// TODO: Find a way to do this without so much class casting.

final FilteredList< T > postfilteredItems = filteredItems

.filtered( s -> ( s instanceof String )

? !( ( String ) s ).startsWith( LIST_CATEGORY_LABEL_PREFIX )

: true );


final KeyCode keyCode = keyEvent.getCode();

switch ( keyCode ) {

case ENTER:

// Code logic addition to prevent errors when the filter found no

// items, so that we revert to the previous value rather than risk

// an invalid blank selection (blank text is like a null).

if ( ( postfilteredItems == null ) || postfilteredItems.isEmpty() ) {

// Cancel the selection by reverting to the previous value, or

// apply the typed "search" value if the Combo Box is editable.

// TODO: Find a way to do this without so much class casting.

final String searchText = searchField.getText().trim();

final T searchDisplayText = ( T ) getDisplayText( ( T ) searchText );

if ( comboBox.isEditable() && !searchText.isEmpty()

&& !searchText.startsWith( LIST_CATEGORY_LABEL_PREFIX ) ) {

comboBox.setValue( searchDisplayText );

}

else {

comboBox.setValue( previousValue );

}

}

else if ( filteredComboBox.getSelectionModel().isEmpty() ) {

// Select the first item if no selection.

filteredComboBox.getSelectionModel().selectFirst();

}


// Hide the temporary Filter Box now that we are done.

comboBox.hide();


// Request focus on the main ComboBox, or else the focus would be

// somewhere else after we remove the temporary Filter Box.

comboBox.requestFocus();


break;

case ESCAPE:

// Cancel the selection by reverting to the previous value.

comboBox.setValue( previousValue );


// Hide the temporary Filter Box now that we are done.

comboBox.hide();


// Request focus on the main ComboBox, or else the focus would be

// somewhere else after we remove the temporary Filter Box.

comboBox.requestFocus();


break;

case TAB:

// Code logic addition to prevent errors when the filter found no

// items, so that we revert to the previous value rather than risk

// an invalid blank selection (blank text is like a null).

if ( ( postfilteredItems == null ) || postfilteredItems.isEmpty() ) {

// Cancel the selection by reverting to the previous value, or

// apply the typed "search" value if the ComboBox is editable.

// TODO: Find a way to do this without so much casting.

final String searchText = searchField.getText().trim();

final T searchDisplayText = ( T ) getDisplayText( ( T ) searchText );

if ( comboBox.isEditable() && !searchText.isEmpty()

&& !searchText.startsWith( LIST_CATEGORY_LABEL_PREFIX ) ) {

comboBox.setValue( searchDisplayText );

}

else {

comboBox.setValue( previousValue );

}

}

else if ( filteredComboBox.getSelectionModel().isEmpty() ) {

// Select the first item if no selection.

filteredComboBox.getSelectionModel().selectFirst();

}


// Hide the temporary Filter Box now that we are done.

comboBox.hide();


// Request focus on the main ComboBox, or else the focus would be

// somewhere else after we remove the temporary Filter Box.

comboBox.requestFocus();


break;

// $CASES-OMITTED$

default:

break;

}

}


Reply all
Reply to author
Forward
0 new messages