Skip to content

Add custom select component with docs and form integration#42093

Open
mdo wants to merge 1 commit intov6-devfrom
v6-custom-select
Open

Add custom select component with docs and form integration#42093
mdo wants to merge 1 commit intov6-devfrom
v6-custom-select

Conversation

@mdo
Copy link
Member

@mdo mdo commented Feb 21, 2026

New custom select menu built on form controls and dropdowns. Needs one more pass because I think I want to rename this from CustomSelect to SelectMenu or something more natural—everything here is custom.

Introduces a new CustomSelect plugin with searchable rich-option rendering, wires it into JS/SCSS exports, and documents usage in the forms docs/sidebar.

Polish theme, control, and code-copy UI details.

Adjusts secondary/button visual tokens and control typography while simplifying code snippet copy button labels across docs shortcodes.

Improve docs examples layout and interactive snippets.

Expands grid column utility coverage in docs, refines docs section-card responsiveness, and updates button playground snippet rendering/highlighting behavior.
@mdo mdo requested review from a team as code owners February 21, 2026 20:47
@mdo mdo added the v6 label Feb 21, 2026
@mdo mdo added this to v6.0.0 Feb 21, 2026
@github-project-automation github-project-automation bot moved this to Inbox in v6.0.0 Feb 21, 2026
}

// Build item content
item.innerHTML = this._buildItemContent(option)

Check failure

Code scanning / CodeQL

DOM text reinterpreted as HTML High

DOM text
is reinterpreted as HTML without escaping meta-characters.

Copilot Autofix

AI 7 days ago

In general, this problem is fixed by avoiding the pattern “DOM text → string concatenation → innerHTML”. Instead, construct DOM nodes programmatically and insert untrusted data using textContent (or setAttribute) so the browser never interprets it as HTML. HTML-based features that truly require markup (e.g., trusted SVG icons) should be kept separate and, when necessary, passed through a strict sanitizer or only accept developer-controlled values.

For this file, the best fix with minimal functional change is:

  • Keep the existing HTML-string-based flow for trusted/controlled fragments (icon SVG, checkmark SVG, and the full data-bs-content override, which is already sanitized).
  • Replace _buildItemContent so it no longer returns an HTML string. Instead:
    • It creates a root <span class="custom-select-content"> node.
    • It appends a text span (<span class="custom-select-text">) with textContent = option.textContent.
    • It appends an optional description span with textContent = option.dataset.bsDescription.
    • It creates and appends an icon/image container span:
      • For data-bs-icon, treat it as trusted developer HTML and insert it via innerHTML in a small, contained span.
      • For data-bs-image, create an <img> element and set img.src and img.alt as attributes (no HTML interpretation).
    • It returns an object containing:
      • contentElement (the DOM node for the text/description block),
      • iconElement (optional DOM node for icon/image),
      • checkmarkHtml (string) or a small DOM node for the checkmark.
  • Update _createItem to append the returned nodes instead of setting item.innerHTML.

To implement this safely and minimally:

  • Change _buildItemContent’s contract from returning a string of HTML to returning an object with DOM elements.
  • Adjust the customContent branch to wrap the sanitized HTML in a container element and then return that element (plus optional checkmark) instead of a raw string.
  • Modify _createItem to:
    • Clear item content (e.g., item.innerHTML = '').
    • Append iconElement if present.
    • Append contentElement.
    • Append checkmark (using insertAdjacentHTML for the existing SVG string or by constructing a node).

No new imports are needed; we only use standard DOM APIs and the already-imported sanitizeHtml helper.

Suggested changeset 1
js/src/custom-select.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/js/src/custom-select.js b/js/src/custom-select.js
--- a/js/src/custom-select.js
+++ b/js/src/custom-select.js
@@ -395,9 +395,24 @@
       item.setAttribute('aria-selected', 'true')
     }
 
-    // Build item content
-    item.innerHTML = this._buildItemContent(option)
+    // Build item content using DOM nodes instead of HTML strings
+    const content = this._buildItemContent(option)
+    item.innerHTML = ''
 
+    if (content.iconElement) {
+      item.append(content.iconElement)
+    }
+
+    if (content.contentElement) {
+      item.append(content.contentElement)
+    }
+
+    if (content.checkmarkElement) {
+      item.append(content.checkmarkElement)
+    } else if (content.checkmarkHtml) {
+      item.insertAdjacentHTML('beforeend', content.checkmarkHtml)
+    }
+
     container.append(item)
     this._items.push({ item, option })
   }
@@ -406,12 +420,27 @@
     // Check for full custom content override
     const customContent = option.dataset.bsContent
     if (customContent) {
-      return this._sanitize(`
-        <span class="custom-select-content">
-          <span class="custom-select-text">${customContent}</span>
-        </span>
-        ${this._config.showCheckmark ? this._getCheckmarkHtml() : ''}
-      `)
+      const wrapper = document.createElement('span')
+      wrapper.classList.add('custom-select-content')
+      const textSpan = document.createElement('span')
+      textSpan.classList.add('custom-select-text')
+      // customContent is developer-provided HTML; keep existing sanitizer behavior
+      textSpan.innerHTML = this._sanitize(customContent)
+      wrapper.append(textSpan)
+
+      let checkmarkElement = null
+      let checkmarkHtml = null
+      if (this._config.showCheckmark) {
+        // Reuse existing SVG HTML for checkmark
+        checkmarkHtml = this._getCheckmarkHtml()
+      }
+
+      return {
+        iconElement: null,
+        contentElement: wrapper,
+        checkmarkElement,
+        checkmarkHtml
+      }
     }
 
     // Build from individual data attributes
@@ -420,29 +449,53 @@
     const description = option.dataset.bsDescription
     const text = option.textContent
 
-    let html = ''
+    let iconElement = null
 
     // Icon (inline SVG) or image - icon is trusted developer content
     if (icon) {
-      html += `<span class="custom-select-icon">${icon}</span>`
+      const iconWrapper = document.createElement('span')
+      iconWrapper.classList.add('custom-select-icon')
+      iconWrapper.innerHTML = icon
+      iconElement = iconWrapper
     } else if (image) {
-      html += `<span class="custom-select-icon"><img src="${this._sanitize(image)}" alt="" class="custom-select-image"></span>`
+      const iconWrapper = document.createElement('span')
+      iconWrapper.classList.add('custom-select-icon')
+      const img = document.createElement('img')
+      img.classList.add('custom-select-image')
+      img.src = this._sanitize(image)
+      img.alt = ''
+      iconWrapper.append(img)
+      iconElement = iconWrapper
     }
 
-    html += '<span class="custom-select-content">'
-    html += `<span class="custom-select-text">${this._sanitize(text)}</span>`
+    const contentElement = document.createElement('span')
+    contentElement.classList.add('custom-select-content')
 
+    const textSpan = document.createElement('span')
+    textSpan.classList.add('custom-select-text')
+    // Use textContent so DOM text is not reinterpreted as HTML
+    textSpan.textContent = text != null ? String(text) : ''
+    contentElement.append(textSpan)
+
     if (description) {
-      html += `<span class="custom-select-description">${this._sanitize(description)}</span>`
+      const descriptionSpan = document.createElement('span')
+      descriptionSpan.classList.add('custom-select-description')
+      descriptionSpan.textContent = String(description)
+      contentElement.append(descriptionSpan)
     }
 
-    html += '</span>'
-
+    let checkmarkElement = null
+    let checkmarkHtml = null
     if (this._config.showCheckmark) {
-      html += this._getCheckmarkHtml()
+      checkmarkHtml = this._getCheckmarkHtml()
     }
 
-    return html
+    return {
+      iconElement,
+      contentElement,
+      checkmarkElement,
+      checkmarkHtml
+    }
   }
 
   _getCheckmarkHtml() {
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Inbox

Development

Successfully merging this pull request may close these issues.

1 participant