Skip to content

Add ReactNode support for dropdown / placeholders in the Select component #539

Description

@aidanlister

This is how we've done it:

iff --git a/dist/esm/components/multi-select/CMultiSelect.js b/dist/esm/components/multi-select/CMultiSelect.js
index 1eec708..10836ab 100644
--- a/dist/esm/components/multi-select/CMultiSelect.js
+++ b/dist/esm/components/multi-select/CMultiSelect.js
@@ -122,7 +122,10 @@ const CMultiSelect = forwardRef((_a, ref) => {
             nativeSelectRef.current.dispatchEvent(new Event('change', { bubbles: true }));
         }
         updatePopper();
-    }, [JSON.stringify(selected)]);
+    // Keyed on the selected values rather than JSON.stringify(selected). An option may now carry a `labelNode`, and
+    // a React element created during render holds an `_owner` fiber, which makes JSON.stringify throw
+    // ("Converting circular structure to JSON"). The values are what this effect actually depends on.
+    }, [selected.map((option) => option.value).join(',')]);
     useEffect(() => {
         visible ? openDropdown() : closeDropdown();
     }, [visible]);
@@ -333,13 +336,11 @@ const CMultiSelect = forwardRef((_a, ref) => {
                     ? selected.map((option) => option.value.toString())
                     : selected.map((option) => option.value)[0], onChange: () => onChange && onChange(selected), onKeyDown: handleNativeSelectKeyDown, ref: nativeSelectRef }),
             React.createElement("div", Object.assign({ className: "form-multi-select-input-group" }, (!search && !disabled && { tabIndex: 0 }), { onClick: () => !disabled && openDropdown(), onKeyDown: handleTogglerKeyDown, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": isOpen, "aria-controls": `multiselect-listbox-${uniqueId}` }, (portal && { 'aria-owns': `multiselect-listbox-${uniqueId}` }), (disabled && { 'aria-disabled': true }), { ref: dropdownRefElement }),
-                React.createElement(CMultiSelectSelection, { ariaTagDeleteLabel: ariaTagDeleteLabel, disabled: disabled, multiple: multiple, onRemove: (option) => !disabled && handleOnOptionClick(option), placeholder: placeholder, search: search, selected: selected, selectionType: selectionType, selectionTypeCounterText: selectionTypeCounterText },
+                React.createElement(CMultiSelectSelection, { ariaTagDeleteLabel: ariaTagDeleteLabel, disabled: disabled, multiple: multiple, onRemove: (option) => !disabled && handleOnOptionClick(option), placeholder: placeholder, search: search, searchValue: searchValue, selected: selected, selectionType: selectionType, selectionTypeCounterText: selectionTypeCounterText },
                     search && (React.createElement("input", Object.assign({ type: "text", className: "form-multi-select-search", disabled: disabled, id: `search${id !== null && id !== void 0 ? id : uniqueId}`, name: `search${name !== null && name !== void 0 ? name : uniqueId}`, autoComplete: "off", "aria-label": ariaSearchLabel, "aria-autocomplete": "list", "aria-controls": `multiselect-listbox-${uniqueId}`, onChange: handleSearchChange, onKeyDown: handleSearchKeyDown }, (selected.length === 0 && { placeholder: placeholder }), (selected.length > 0 &&
                         selectionType === 'counter' && {
                         placeholder: `${selected.length} ${selectionTypeCounterText}`,
                     }), (selected.length > 0 &&
-                        !multiple && { placeholder: selected.map((option) => option.label)[0] }), (multiple &&
-                        selected.length > 0 &&
                         selectionType !== 'counter' && { size: searchValue.length + 2 }), { ref: searchRef }))),
                     !search && selected.length === 0 && (React.createElement("span", { className: "form-multi-select-placeholder" }, placeholder))),
                 React.createElement("div", { className: "form-multi-select-buttons" },
diff --git a/dist/esm/components/multi-select/CMultiSelectSelection.js b/dist/esm/components/multi-select/CMultiSelectSelection.js
index 36a2be1..2568d9c 100644
--- a/dist/esm/components/multi-select/CMultiSelectSelection.js
+++ b/dist/esm/components/multi-select/CMultiSelectSelection.js
@@ -2,7 +2,7 @@ import React, { forwardRef } from 'react';
 import classNames from '../../_virtual/index.js';
 import PropTypes from 'prop-types';
 
-const CMultiSelectSelection = forwardRef(({ ariaTagDeleteLabel = 'Remove', children, disabled, multiple, placeholder, onRemove, search, selected = [], selectionType, selectionTypeCounterText, }, ref) => {
+const CMultiSelectSelection = forwardRef(({ ariaTagDeleteLabel = 'Remove', children, disabled, multiple, placeholder, onRemove, search, searchValue = '', selected = [], selectionType, selectionTypeCounterText, }, ref) => {
     return (React.createElement("span", { className: classNames('form-multi-select-selection', {
             'form-multi-select-selection-tags': multiple && selectionType === 'tags',
         }), "aria-live": "polite", ref: ref },
@@ -17,7 +17,7 @@ const CMultiSelectSelection = forwardRef(({ ariaTagDeleteLabel = 'Remove', child
             selected.map((option, index) => {
                 if (selectionType === 'tags') {
                     return (React.createElement("span", { className: "form-multi-select-tag", key: index },
-                        option.label,
+                        option.labelNode ?? option.label,
                         !disabled && !option.disabled && (React.createElement("button", { className: "form-multi-select-tag-delete", type: "button", "aria-label": `${ariaTagDeleteLabel} ${option.label}`.trim(), onClick: () => onRemove === null || onRemove === void 0 ? void 0 : onRemove(option) }))));
                 }
                 return;
@@ -25,10 +25,13 @@ const CMultiSelectSelection = forwardRef(({ ariaTagDeleteLabel = 'Remove', child
         multiple &&
             selectionType === 'text' &&
             selected.map((option, index) => (React.createElement("span", { key: index },
-                option.label,
+                option.labelNode ?? option.label,
                 index === selected.length - 1 ? '' : ',',
                 "\u00A0"))),
-        !multiple && !search && selected.map((option) => option.label)[0],
+        // Rendered as content rather than the search input's placeholder attribute, so a single select's value
+        // takes normal text colour and can carry a `labelNode` badge. Stepping aside once the user types is what
+        // the placeholder gave us for free.
+        !multiple && !searchValue && selected.map((option) => option.labelNode ?? option.label)[0],
         children));
 });
 CMultiSelectSelection.propTypes = {
@@ -38,6 +41,7 @@ CMultiSelectSelection.propTypes = {
     multiple: PropTypes.bool,
     onRemove: PropTypes.func,
     placeholder: PropTypes.string,
+    searchValue: PropTypes.string,
     search: PropTypes.oneOfType([
         PropTypes.bool,
         PropTypes.oneOf(['external', 'global']),
diff --git a/dist/esm/components/multi-select/types.d.ts b/dist/esm/components/multi-select/types.d.ts
index 8015721..8248e3a 100644
--- a/dist/esm/components/multi-select/types.d.ts
+++ b/dist/esm/components/multi-select/types.d.ts
@@ -3,6 +3,13 @@ export type Option = {
     disabled?: boolean;
     selected?: boolean;
     label: string;
+    /**
+     * Patched in by workforce. Rendered instead of `label` wherever CoreUI renders the selection as content
+     * (multi-select tags, `selectionType="text"`, and a single select with `search` off), so a selected value can
+     * carry markup such as a status badge. `label` is still required, and remains what CoreUI uses for the
+     * `placeholder` and `aria-label` attributes, for the mirrored native <select>, and for its own search matching.
+     */
+    labelNode?: import('react').ReactNode;
     value: number | string;
     [key: string]: number | string | any;
 };

Which lets us build something like this:

Image

(which is what we had with react-select which we're finally porting over so we're fully CoreUI)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions