Skip to content

Commit

Permalink
11/Input/SearchableSelect groundwork
Browse files Browse the repository at this point in the history
  • Loading branch information
catenglaender committed Jan 8, 2025
1 parent a0be97e commit 60310f4
Show file tree
Hide file tree
Showing 21 changed files with 1,201 additions and 37 deletions.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/**
* This file is part of ILIAS, a powerful learning management system
* published by ILIAS open source e-Learning e.V.
*
* ILIAS is licensed with the GPL-3.0,
* see https://www.gnu.org/licenses/gpl-3.0.en.html
* You should have received a copy of said license along with the
* source code, too.
*
* If this is not the case or you just want to try ILIAS, you'll find
* us at:
* https://www.ilias.de
* https://github.com/ILIAS-eLearning
*/

/**
* Searchable Select Component
* JS features:
* - search bar input filters (hides) list items
* - button to clear the filter
* - expanding and collapsing the component (hiding and showing elements) with button triggers
* SCSS features:
* - pushing checked items to the top of the list using flex-box order
* - component expanding animation
* - item switching position animation
* @author Ferdinand Engländer <[email protected]>
*/
export default class SearchableSelect {
/**
* @type {HTMLFieldSetElement}
*/
searchableSelect;

/**
* @type {HTMLInputElement}
*/
searchbar;

/**
* @type {HTMLFieldSetElement}
*/
selectionComponent;

/**
* @type {string}
*/
listType;

/**
* @type {HTMLDivElement}
*/
itemList;

/**
* @type {NodeList}
*/
selectionComponentItems;

/**
* @type {HTMLButtonElement}
*/
engageButton;

/**
* @type {HTMLButtonElement}
*/
disengageButton;

/**
* @type {HTMLButtonElement}
*/
clearSearchButton;

/**
* @type {boolean}
*/
#isFiltered;

/**
* @type {boolean}
*/
#isEngaged;

constructor(input_id) {
/* DOM Elements */
this.searchableSelect = document.getElementById(input_id);
// first input is always search bar
this.searchbar = this.searchableSelect.getElementsByTagName("input")[0];
// second .c-input is always the nested selection component
this.selectionComponent = this.searchableSelect.querySelectorAll(".c-input__field .c-input")[1];
this.itemList = this.selectionComponent.querySelector(".c-input__field");
console.log("item list set up: " + this.itemList)
this.listType = this.selectionComponent.getAttribute("data-il-ui-component");
switch (this.listType) {
case "multi-select-field-input":
this.selectionComponentItems = this.selectionComponent.querySelectorAll(".c-input__field ul li");
break;
case "radio-field-input":
this.selectionComponentItems = this.selectionComponent.querySelectorAll(".c-input__field .c-field-radio .c-field-radio__item");
break;
}

/* Buttons */
this.clearSearchButton = this.searchableSelect.getElementsByTagName('button')[0];
this.disengageButton = this.searchableSelect.getElementsByTagName("button")[1];
this.engageButton = this.searchableSelect.getElementsByTagName("button")[2];

/* Initialize states */
this.isEngaged = false; // will also set isFiltered false

/* Event Listeners */
this.filterItemsSearch = this.filterItemsSearch.bind(this);
this.searchbar.addEventListener('input', this.filterItemsSearch);

this.clearSearchButton.addEventListener('click', () => { this.isFiltered = false });

this.disengageButton.addEventListener('click', () => { this.isEngaged = false });

this.engageButton.addEventListener('click', () => { this.isEngaged = true });

if (this.listType === "radio-field-input") {
this.scrollListToTop = this.scrollListToTop.bind(this);
this.selectionComponentItems.forEach(item => {
console.log("item: " + item)
item.addEventListener('change', this.scrollListToTop);
})

}

}

/**
* Getter for isEngaged state
* @returns {boolean}
*/
get isEngaged() {
return this.#isEngaged;
}

/**
* Setter for isEngaged state
* Disengaging the component resets the filter
* @param {boolean} value
*/
set isEngaged(value) {
if (this.#isEngaged === value) return; // Avoid unnecessary updates
this.#isEngaged = value;

switch (value) {
case true:
this.searchableSelect.classList.add("engaged")
break;
case false:
this.searchableSelect.classList.remove("engaged")
this.isFiltered = false;
break;
}
}

/**
* Getter for isFiltered state
* @returns {boolean}
*/
get isFiltered() {
return this.#isFiltered;
}

/**
* Setter for isFiltered state
* @param {boolean} value
*/
set isFiltered(value) {
if (this.#isFiltered === value) return; // Avoid unnecessary updates
this.#isFiltered = value;

switch (value) {
case true:
this.clearSearchButton.style.removeProperty("display");
break;
case false:
this.searchbar.value = '';
this.clearSearchButton.style.display = "none";
this.resetItemsDisplay();
break;
}
}

/**
* Filter items based on search input
* @param {Event} event
*/
filterItemsSearch(event) {
const value = event.target.value.toLowerCase();

this.isFiltered = !!value; // negates any search term input to false then flips it to true

this.selectionComponentItems.forEach(item => {
const itemText = item.textContent.toLowerCase();
const isMatch = itemText.includes(value);
if (isMatch) {
this.showItem(item);
} else {
this.hideItem(item);
}
});
}

/**
* Reset the display of all items
*/
resetItemsDisplay() {
this.selectionComponentItems.forEach(item => this.showItem(item));
}

/**
* Show a specific item
* @param {HTMLElement} item
*/
showItem(item) {
item.style.transform = "scale(1,1)";
item.style.removeProperty("display");
}

/**
* Hide a specific item
* @param {HTMLElement} item
*/
hideItem(item) {
item.style.transform = "scale(1,0)";
item.style.display = "none";
}

scrollListToTop(event) {
console.log("attempting to scroll to top because of event: " + event)
this.itemList.scrollTo({
top: 0,
behavior: "smooth",
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import SearchableSelect from './searchableselect.class';
import Textarea from '../Textarea/textarea.class';

/**
* @author Ferdinand Engländer <[email protected]>
*/
export default class SearchableSelectFactory {
/**
* @type {Array<string, SearchableSelect>}
*/
instances = [];

/**
* @param {string} input_id
* @return {void}
* @throws {Error} if the input was already initialized.
*/
init(input_id, searchfield_id) {
console.log(`Factory was called with input id: ${input_id}`);
if (undefined !== this.instances[input_id]) {
throw new Error(`SearchableSelect with input-id '${input_id}' has already been initialized.`);
}

this.instances[input_id] = new SearchableSelect(input_id, searchfield_id);
}

/**
* @param {string} input_id
* @return {SearchableSelect|null}
*/
get(input_id) {
return this.instances[input_id] ?? null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@
import il from 'ilias';
import TextareaFactory from './Textarea/textarea.factory';
import MarkdownFactory from './Markdown/markdown.factory';
import SearchableSelectFactory from './SearchableSelect/searchableselect.factory';

il.UI = il.UI || {};
il.UI.Input = il.UI.Input || {};

(function (Input) {
Input.textarea = new TextareaFactory();
Input.markdown = new MarkdownFactory();
Input.searchableselect = new SearchableSelectFactory();
}(il.UI.Input));
55 changes: 55 additions & 0 deletions components/ILIAS/UI/src/Component/Input/Field/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,61 @@ public function password(string $label, ?string $byline = null): Password;
*/
public function select(string $label, array $options, ?string $byline = null): Select;

/**
* ---
* description:
* purpose: >
* A searchable select is used to allow users to pick among a large number of options.
* composition: >
* The component can be collapsed and expanded.
* When collapsed, only the current selection (if any) is shown.
* The expanded searchable select consists out of a search field, a scrollable container of radio or
* multi-select options and shy buttons that effect how many options are presented.
* effect:
* When expanded, all possible options of a radio or multi-select input component are shown. Also, a search bar
* appears and can be used to filter for the desired option.
* When a search term is entered, a button lets the user clear the search field and reset the filter.
* If radio buttons are provided, only one option is selectable.
* If multi-select is provided, multiple options are selectable.
* Collapsing the component also clears the filter. The collapsed view is never filtered.
* rivals:
* Checkbox field: Use a checkbox field for binary yes/no choices or a list of a few (e.g. 5 or less) combinable
* options.
* Select field: >
* Use a select field for few (5 or less) mutually exclusive choices.
* Radio buttons: >
* Use radio buttons for few (5 or less) mutually exclusive choices when it's important to also see which
* options the user is not choosing.
* Optional Group and Switchable Group: >
* If several different input fields need to be initially hidden to de-clutter the view, use an optional or
* switchable group. Note that activating the group is already a choice. Only expanding the searchable select
* is a visual reveal and not an input to the form.
*
* rules:
* usage:
* 1: searchable selection MAY be used for choosing from a large number (5 and more) of predetermined options.
* 2: It MAY be used to simplify setting screens where users want to quickly check a past choice without
* having to see all the alternative options that weren't chosen.
*
* composition:
* 1: the select search MUST be filled with a radio or multi-select component.
*
* wording:
* 1: >
* The label SHOULD indicate how the selection is used or what will be the result of the choice
* e.g. Email Recipient(s), Email Wording
* 2: >
* The label of the given multi-select or radio component SHOULD indicate where the options are from or
* further describe their nature e.g. Group Members, Templates
*
* ---
* @param string $label
* @param Radio|MultiSelect $input
* @param string|null $byline
* @return \ILIAS\UI\Component\Input\Field\SearchableSelect
*/
public function searchableSelect(string $label, MultiSelect|Radio $input, ?string $byline = null): SearchableSelect;

/**
* ---
* description:
Expand Down
28 changes: 28 additions & 0 deletions components/ILIAS/UI/src/Component/Input/Field/SearchableSelect.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

/**
* This file is part of ILIAS, a powerful learning management system
* published by ILIAS open source e-Learning e.V.
*
* ILIAS is licensed with the GPL-3.0,
* see https://www.gnu.org/licenses/gpl-3.0.en.html
* You should have received a copy of said license along with the
* source code, too.
*
* If this is not the case or you just want to try ILIAS, you'll find
* us at:
* https://www.ilias.de
* https://github.com/ILIAS-eLearning
*
*********************************************************************/

declare(strict_types=1);

namespace ILIAS\UI\Component\Input\Field;

/**
* This describes searchable select fields.
*/
interface SearchableSelect extends Group
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ public function select(string $label, array $options, ?string $byline = null): I
return new Select($this->data_factory, $this->refinery, $label, $options, $byline);
}

/**
* @inheritdoc
*/
public function searchableSelect(string $label, I\MultiSelect|I\Radio $input, ?string $byline = null): I\SearchableSelect
{
return new SearchableSelect($this->data_factory, $this->refinery, $this->lng, $input, $label, $byline);
}

/**
* @inheritdoc
*/
Expand Down
Loading

0 comments on commit 60310f4

Please sign in to comment.