UNPKG

gd-bs

Version:

Bootstrap JavaScript, TypeScript and Web Components library.

870 lines (751 loc) 32.4 kB
import { IDropdown, IDropdownItem, IDropdownProps } from "./types"; import { ICheckboxGroup, ICheckboxGroupItem } from "../checkboxGroup/types"; import { IFloatingUI } from "../floating-ui/types"; import { Base } from "../base"; import { ButtonClassNames, ButtonTypes } from "../button"; import { CheckboxGroup, CheckboxGroupTypes } from "../checkboxGroup" import { FloatingUI, FloatingUIPlacements, FloatingUITypes } from "../floating-ui"; import { DropdownFormItem } from "./formItem"; import { DropdownItem } from "./item"; import { HTML, HTMLForm, HTMLNavItem, HTMLSplit } from "./templates"; /** * Dropdown Types */ export const DropdownPlacements = FloatingUIPlacements; export const DropdownTypes = ButtonTypes; // Gets the template const GetHTML = (props: IDropdownProps) => { // See if we are rendering items for a form if (props.formFl) { return HTMLForm; } // See if we are rendering for a nav bar if (props.navFl) { return HTMLNavItem; } // See if we are rendering a split button dropdown if (props.isSplit) { return HTMLSplit; } // Return the default template return HTML; } /** * Dropdown * @property props - The dropdown properties. */ class _Dropdown extends Base<IDropdownProps> implements IDropdown { private _autoSelect: boolean = null; private _cb: ICheckboxGroup = null; private _elMenu: HTMLElement; private _elSearch: HTMLInputElement; private _floatingUI: IFloatingUI = null; private _initFl: boolean = false; private _items: Array<DropdownFormItem | DropdownItem> = null; // Constructor constructor(props: IDropdownProps, template: string = GetHTML(props)) { super(template, props); // Configure the dropdown this.configure(); // Configure the events this.configureEvents(); // Configure search this.configureSearch(); // Configure the parent this.configureParent(); // Set the flag this._initFl = true; } // Configure the card group private configure() { // See if this is for a form if (this.props.formFl) { // Configure the dropdown for a form this.configureForm(); } // Else, see if this is for a nav bar else if (this.props.navFl) { // Configure the dropdown for a nav bar this.configureNavBar(); } else { // Configure the dropdown this.configureDefault(); } // Render the items this.renderItems(); // See if values were defined if (this.props.value) { // Set the values this.setValue(this.props.value); } // Set the menu element this._elMenu = this.el.querySelector(".dropdown-menu"); if (this._elMenu) { // See if we are only rendering a menu if (this.props.menuOnly) { // Update the element this.el = this._elMenu; } } // Set the dark theme this.props.isDark ? this.setTheme(true) : null; } // Configures the dropdown private configureDefault() { // Set the attributes this.props.title ? this.el.title = this.props.title : null; this.props.dropLeft ? this.el.classList.add("dropstart") : null; this.props.dropRight ? this.el.classList.add("dropend") : null; this.props.dropUp ? this.el.classList.add("dropup") : null; // Set the type let btnType = ButtonClassNames.getByType(this.props.type) || ButtonClassNames.getByType(DropdownTypes.Primary); // See if this is a split button if (this.props.isSplit) { // Update a label let label = this.el.querySelector("button"); if (label) { label.classList.add(btnType); label.disabled = this.props.isReadonly ? true : false; label.innerHTML = this.props.label == null ? "" : this.props.label; // Set the click event to disable the postback label.addEventListener("click", ev => { ev.preventDefault(); }); } } else { // Update the label let label = this.el.querySelector(".dropdown-toggle"); if (label) { label.innerHTML = this.props.label == null ? "" : this.props.label; } } // Update the dropdown let toggle = this.el.querySelector(".dropdown-toggle"); if (toggle) { toggle.classList.add(btnType); toggle.disabled = this.props.isReadonly ? true : false; toggle.setAttribute("aria-label", this.props.label || ""); } // See if we are rendering the menu only let menu = this.el.querySelector(".dropdown-menu") as HTMLElement; if (menu) { // See if we are rendering the menu only if (this.props.menuOnly) { // Update the menu this.props.id ? menu.id = this.props.id : null; this.props.className ? menu.classList.add(this.props.className) : null; } else { // Update the menu this.props.id ? menu.setAttribute("aria-labelledby", this.props.id) : null; } // See if a button class name exists let classNames = (this.props.btnClassName || "").split(' '); for (let i = 0; i < classNames.length; i++) { // Ensure the class name exists let className = classNames[i]; if (className) { // Add the class name (this.props.menuOnly ? menu : toggle).classList.add(className); } } } } // Configure the events private configureEvents() { // Set the auto select property this._autoSelect = typeof (this.props.autoSelect) === "boolean" ? this.props.autoSelect : true; // See if this is a select element and a change event exists let menu = this.el.querySelector("select"); if (menu) { // Add a change event menu.addEventListener("change", ev => { // See if multiple options are allowed if (this.props.multi == true) { // See if we are selecting the values if (this._autoSelect) { // Parse the items for (let i = 0; i < this._items.length; i++) { let item = this._items[i] as DropdownFormItem; // Update the flag item.isSelected = (item.el as HTMLOptionElement).selected; } } // Call the change event this.props.onChange ? this.props.onChange(this.getValue(), ev) : null; } else { // Get the selected value let selectedValue = ((ev.target as HTMLSelectElement).value || "").trim(); // Parse the items for (let i = 0; i < this._items.length; i++) { let item = this._items[i]; // Replace special characters let value = (item.props.text || ""); // See if this item was selected if (selectedValue == value) { // Ensure this item is selected if (this._autoSelect && !item.isSelected) { item.toggle(); } // Call the change event this.props.onChange ? this.props.onChange(item.props, ev) : null; } else { // Unselect the other values if (this._autoSelect && item.isSelected) { item.toggle(); } } } } }); } // Get the toggle let toggle = this.el.querySelector(".dropdown-toggle") as HTMLElement; if (toggle && this._elMenu) { // Set the type, based on the current dropdown type let popoverType = FloatingUITypes.LightBorder; switch (this.props.type) { case DropdownTypes.Danger: case DropdownTypes.OutlineDanger: popoverType = FloatingUITypes.Danger; break; case DropdownTypes.Dark: case DropdownTypes.OutlineDark: popoverType = FloatingUITypes.Dark; break; case DropdownTypes.Info: case DropdownTypes.OutlineInfo: popoverType = FloatingUITypes.Info; break; case DropdownTypes.Light: case DropdownTypes.OutlineLight: case DropdownTypes.Link: case DropdownTypes.OutlineLink: popoverType = FloatingUITypes.Light; break; case DropdownTypes.Primary: case DropdownTypes.OutlinePrimary: popoverType = FloatingUITypes.Primary; break; case DropdownTypes.Secondary: case DropdownTypes.OutlineSecondary: popoverType = FloatingUITypes.Secondary; break; case DropdownTypes.Success: case DropdownTypes.OutlineSuccess: popoverType = FloatingUITypes.Success; break; case DropdownTypes.Warning: case DropdownTypes.OutlineWarning: popoverType = FloatingUITypes.Warning; break; } // Create the menu this._floatingUI = FloatingUI({ className: "floating-dropdown", elContent: this._elMenu, elTarget: toggle, placement: typeof (this.props.placement) === "number" ? this.props.placement : FloatingUIPlacements.BottomStart, theme: popoverType, onShow: () => { // See if the search element exists if (this._elSearch) { // Clear the search this._elSearch.value = ""; // Show all the items for (let i = 0; i < this._items.length; i++) { this._items[i].show(); } } }, options: { arrow: false, flip: true, shift: true, trigger: "click" } }); } } // Configures the dropdown for a form private configureForm() { // Configure the label let elLabel = this.el.querySelector("label") as HTMLElement; if (elLabel) { let label = this.props.label == null ? "" : this.props.label; if (label) { // Set the label elLabel.innerHTML = label; } else { // Remove the label elLabel.parentNode.removeChild(elLabel); } } // Update the dropdown let dropdown = this.el.querySelector("select"); if (dropdown) { dropdown.className = this.props.className || ""; dropdown.classList.add("form-select"); dropdown.disabled = this.props.isReadonly ? true : false; dropdown.multiple = this.props.multi ? true : false; dropdown.required = this.props.required ? true : false; this.props.title ? dropdown.title = this.props.title : null; } } // Configure the item events private configureItemEvents(item: DropdownFormItem | DropdownItem) { // Ensure this isn't a header/divider if (item.props.isDivider || item.props.isHeader) { return; } // See if multi selections is not allowed if (this.props.multi != true) { // Add a click event item.el.addEventListener("click", ev => { // See if an item was selected, and is disabled if (item.props.isDisabled == true) { // Ignore the click event return; } // Parse the items for (let i = 0; i < this._items.length; i++) { let selectedItem = this._items[i]; // Skip this item if (item.el.innerHTML == selectedItem.el.innerHTML) { continue; } // Ensure this item is selected if (selectedItem.isSelected) { // Unselect the item selectedItem.toggle(); } } // See if we are updating the label if (this.props.updateLabel) { let selectedItem = this.getValue() as IDropdownItem; // Set the label let toggle = this.el.querySelector(".dropdown-toggle"); if (toggle) { toggle.innerHTML = selectedItem ? selectedItem.text : this.props.label; } } }); } // Add a click event item.el.addEventListener("click", ev => { // Prevent other events to occur ev.stopPropagation(); // Ensure this isn't a multi-select if (this.isMulti != true) { // Toggle the menu if it's visible this.isVisible ? this.toggle() : null; } // Else, see if we are updating the label for a multi-dropdown else if (this.props.updateLabel) { // Set the selected values let selectedItems: IDropdownItem[] = this.getValue(); let selectedValues = []; for (let i = 0; i < selectedItems.length; i++) { // Append the value selectedValues.push(selectedItems[i].text); } // Set the label let toggle = this.el.querySelector(".dropdown-toggle"); if (toggle) { // Set the label toggle.innerHTML = selectedValues.length == 0 ? this.props.label : selectedValues.join(', '); } } // Execute the event this.props.onChange ? this.props.onChange(this.getValue(), ev) : null; }); } // Configures the dropdown for a nav bar private configureNavBar() { // Update the link let link = this.el.querySelector("a"); if (link) { link.id = ("navbarDDL" + (this.props.label == null ? "" : this.props.label)).replace(/ /g, ''); this.props.title ? link.title = this.props.title : null; this.props.isReadonly ? link.setAttribute("aria-disabled", "true") : null; link.innerHTML = this.props.label == null ? "" : this.props.label; } // See if we are rendering the menu only let menu = this.el.querySelector(".dropdown-menu"); if (menu) { if (this.props.menuOnly) { // Update the menu this.props.id ? menu.id = this.props.id : null; menu.className = this.props.className ? this.props.className : ""; menu.classList.add("dropdown-menu"); } else { // Update the menu this.props.id ? menu.setAttribute("aria-labelledby", this.props.id) : null; } } } // Configures the search option for the dropdown private configureSearch() { // See if search is enabled and the menu exists if (this.props.search != true || this._elMenu == null) { return; } // Create the search textbox this._elSearch = document.createElement("input"); this._elSearch.classList.add("form-control"); this._elSearch.type = "search"; this._elSearch.placeholder = "Search for item..."; // Insert the item as the first element this._elMenu.firstChild ? this._elMenu.insertBefore(this._elSearch, this._elMenu.firstChild) : this._elMenu.appendChild(this._elSearch); // Create the empty text let elEmptyText = document.createElement("h6") elEmptyText.classList.add("dropdown-header"); elEmptyText.classList.add("d-none"); elEmptyText.innerHTML = "No items were found..."; this._elMenu.appendChild(elEmptyText); // Add the element to the ignore list this._floatingUI.addIgnoreElement(this._elSearch); // Add the event this._elSearch.addEventListener("input", () => { // Get the value let searchText = (this._elSearch.value || "").toLocaleLowerCase(); // Set the flags let itemsFound = false; let showAll = searchText == ""; // Hide the empty text elEmptyText.classList.add("d-none"); // Parse the items for (let i = 0; i < this._items.length; i++) { let item = this._items[i]; // See if we are showing all the items if (showAll) { // Show the item item.show(); } else { // See if the value contains the text if ((item.props.text || "").toLowerCase().indexOf(searchText) >= 0) { // Show the item item.show(); // Set the flag itemsFound = true; } else { // Hide the item item.hide(); } } } // See if no items were found if (!showAll && !itemsFound) { // Show the empty message elEmptyText.classList.remove("d-none"); } }); } // Generates the checkbox items private generateCheckboxItems(): ICheckboxGroupItem[] { let cbItems: ICheckboxGroupItem[] = []; // Parse the items let items = this.props.items || []; for (let i = 0; i < items.length; i++) { let item = items[i]; // Create the checkbox item cbItems.push({ data: item, isDisabled: item.isDisabled, isSelected: item.isSelected, label: item.text, onChange: item.onClick, type: CheckboxGroupTypes.Checkbox }); } // Return the items return cbItems; } // Generates the checkbox value private generateCheckboxValue(currentValues: string | string[] | IDropdownItem[]): string[] { let values: string[] = []; // Ensure a value exists if (currentValues == null) { return values; } // Ensure it's an array if (typeof (currentValues) === "string") { // Make it an array currentValues = [currentValues]; } // Parse the current values for (let i = 0; i < currentValues.length; i++) { let currentValue = currentValues[i]; let currentItem: IDropdownItem = {}; // See if this is a string if (typeof (currentValue) == "string") { // Set the text and value properties currentItem.text = currentValue; currentItem.value = currentValue; } else { // Set the item currentItem = currentValue; } // Find the item let item = this.props.items?.find((item) => { return item.value == currentValue || item.text == currentValue; }); if (item) { // Add the text property values.push(item.text); } } // Return the values return values; } // Handles the click event outside of the menu to close it private handleClick = (ev: Event) => { // See if we clicked within the menu if (!ev.composedPath().includes(this._elMenu)) { if (this.isVisible) { // Hide the menu this.toggle(); } else { // Remove this event (This shouldn't happen, but to be safe) document.body.removeEventListener("click", this.handleClick); } } } // Renders the items private renderItems() { // Clear the items this._items = []; // Get the menu let menu = this.el.querySelector(".dropdown-menu") || this.el.querySelector("select") || (this.el.classList.contains("dropdown-menu") ? this.el : null); if (menu) { // See if we are creating checkboxes if (this.props.isCheckbox) { // Render the checkbox this._cb = CheckboxGroup({ className: "m-2", el: menu, items: this.generateCheckboxItems(), multi: this.props.multi, value: this.generateCheckboxValue(this.props.value), onChange: (selectedItems, allItems, ev) => { // See if this is a multi checkbox if (this.props.multi) { // Toggle the menu if it's not visible setTimeout(() => { this.isVisible ? null : this.toggle(); }, 25); } // Pass the current values this.props?.onChange(selectedItems, ev); } }); } else { let isForm = menu.nodeName == "SELECT"; let values: string[] = []; // Parse the items let items = this.props.items || []; for (let i = 0; i < items.length; i++) { // Create the item let item = isForm ? new DropdownFormItem(items[i], this.props) : new DropdownItem(items[i], this.props); this._items.push(item); // See if this item is selected if (item.isSelected) { values.push(item.props.value || item.props.text); } // See if this isn't for a form if (!isForm) { // Configure the item events this.configureItemEvents(item); } // Add the item to the menu menu.appendChild(item.el); } // Set the value this.setValue(this.props.multi ? values : values[0]); // See if this is a form if (isForm) { // Ensure the selected values match the index let idx = (menu as HTMLSelectElement).selectedIndex; if (idx >= 0 && idx < this._items.length) { // Parse the items and ensure none are toggled except for the selected index for (let i = 0; i < this._items.length; i++) { // Ensure it's not selected if (i != idx && this._items[i].isSelected) { this._items[i].toggle(); } // Else, toggle it if it's the selected item else if (idx == i && this._items[i].isSelected == false) { this._items[i].toggle(); } } } } } } } /** * Public Interface */ // Disables the button disable() { // Get the buttons let buttons = this.el.querySelectorAll("button"); for (let i = 0; i < buttons.length; i++) { // Disable the button (buttons[i] as HTMLButtonElement).disabled = true; } } // Enables the button enable() { // Get the buttons let buttons = this.el.querySelectorAll("button"); for (let i = 0; i < buttons.length; i++) { // Enable the button (buttons[i] as HTMLButtonElement).disabled = false; } } // Gets the value getValue() { let values = []; // See if the checkboxes exist if (this._cb) { // Get the values let items = (this._cb.getValue().selectedItems) as ICheckboxGroupItem[]; items = typeof (items["length"]) === "number" ? items : [items] as any; // Parse the items for (let i = 0; i < items.length; i++) { // Add the value values.push(items[i].data); } } else { // Parse the items for (let i = 0; i < this._items.length; i++) { let item = this._items[i]; // Skip disabled items if ((item.el as HTMLOptionElement).disabled) { continue; } // See if this item is selected if (item.isSelected) { // Add the value values.push(item.props); } } } // Return the value return this.props.multi ? values : values[0]; } // Returns true if the dropdown allows multiple selections get isMulti(): boolean { return this.props.multi; } // Returns true if the dropdown menu is visible get isVisible(): boolean { return this._floatingUI ? this._floatingUI.isVisible : false; } // The floating ui menu get floatingUI(): IFloatingUI { return this._floatingUI; } // Sets the dropdown items setItems(newItems: Array<IDropdownItem> = []) { // Update the properties this.props.items = newItems; // See if we are rendering checkboxes if (this._cb) { // Set the items this._cb.setItems(this.generateCheckboxItems()); return; } // Get the menu let menu: HTMLSelectElement = this.el.querySelector(".dropdown-menu") || this.el.querySelector("select") || (this.el.classList.contains("dropdown-menu") ? this.el : null); if (menu) { // Clear the menu while (menu.firstChild) { menu.removeChild(menu.firstChild); } // Clear the current value menu.value = ""; // Render the items this.renderItems(); // Parse the items for (let i = 0; i < newItems.length; i++) { let item = newItems[i]; // See if the item is selected if (item.isSelected) { menu.value = item.text; break; } } } // Configure search this.configureSearch(); } // Sets the label of the dropdown setLabel(value: string = "") { // Get the dropdown let ddl = this.el.querySelector(".dropdown-toggle"); if (ddl) { // Set the inner html ddl.innerHTML = value; ddl.setAttribute("aria-label", value); } } // Enables/Disables the dark theme setTheme(isDark: boolean) { // Get the menu // See if we are setting the dark theme if (isDark) { // Set the theme this._elMenu.classList.add("dropdown-menu-dark"); } else { // Set the theme this._elMenu.classList.remove("dropdown-menu-dark"); } } // Sets the button type setType(ddlType: number) { // Parse the element types to search for let elTypes = ["button", ".dropdown-toggle"]; for (let i = 0; i < elTypes.length; i++) { let el = this.el.querySelector(elTypes[i]); if (el) { // Parse the class names ButtonClassNames.parse(className => { // Remove the class names el.classList.remove(className); }); // Set the class name let className = ButtonClassNames.getByType(ddlType); className ? el.classList.add(className) : null; } } } // Sets the dropdown value setValue(value) { // Ensure it's an array let values = value == null ? [] : (typeof (value.length) === "number" && typeof (value) !== "string" ? value : [value]); // See if this is a checkbox if (this._cb) { // Set the value this._cb.setValue(this.generateCheckboxValue(value)); return; } // Parse the items for (let i = 0; i < this._items.length; i++) { let item = this._items[i]; // Toggle checked items item.isSelected ? item.toggle() : null; } // Parse the values for (let i = 0; i < values.length; i++) { let value = values[i]; let ddlText = value ? value.text || value : null; let ddlValue = value ? value.value || value : null; // Parse the items for (let j = 0; j < this._items.length; j++) { let item = this._items[j]; // See if this is the target item if (item.props.value == undefined) { // Select this item if the text matches item.props.text == ddlText ? item.toggle() : null; } else { // Select this item if the value matches item.props.value == ddlValue ? item.toggle() : null; } } } // See if this is a form let ddl = this.el.querySelector("select"); if (ddl) { // Ensure the selected values match the index if (this._items[ddl.selectedIndex] && this._items[ddl.selectedIndex].isSelected == false) { // Select the item this._items[ddl.selectedIndex].toggle(); } } // See if we are updating the label if (this.props.updateLabel) { // See if a value exists if (value && typeof (value) === "string") { // Set the label this.setLabel(value); } // Else, see if label exists else if (this.props.label) { // Set the label this.setLabel(this.props.label); } } // See if a change event exists if (this._initFl && this.props.onChange) { // Execute the change event this.props.onChange(this.getValue()); } } // Toggles the menu toggle() { // Toggle the popover this._floatingUI ? this._floatingUI.toggle() : null; } } export const Dropdown = (props: IDropdownProps, template?: string): IDropdown => { return new _Dropdown(props, template); }