]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Add new chip input and chips (#42008)
authorMark Otto <markd.otto@gmail.com>
Tue, 20 Jan 2026 05:01:55 +0000 (21:01 -0800)
committerGitHub <noreply@github.com>
Tue, 20 Jan 2026 05:01:55 +0000 (21:01 -0800)
* Add new chip input and chips

* More fixes to build and linter

* Bump bundlewatch

* fix dupe id

.bundlewatch.config.json
build/zip-examples.mjs
js/src/chip-input.js [new file with mode: 0644]
package.json
scss/_chip.scss [new file with mode: 0644]
scss/bootstrap.scss
scss/forms/_chip-input.scss [new file with mode: 0644]
scss/forms/index.scss
site/data/sidebar.yml
site/src/content/docs/forms/chip-input.mdx [new file with mode: 0644]

index 6995d6a0aa016125d923939d3d4f8f7ed59c1fd1..4ee0c4022e86b0dd61344ca05d2e7b9e5b1e1cb6 100644 (file)
     },
     {
       "path": "./dist/css/bootstrap.css",
-      "maxSize": "38.75 kB"
+      "maxSize": "39.25 kB"
     },
     {
       "path": "./dist/css/bootstrap.min.css",
-      "maxSize": "37.25 kB"
+      "maxSize": "37.75 kB"
     },
     {
       "path": "./dist/js/bootstrap.bundle.js",
index 6d71c2e10bbf4655969863c2cfdfe95b448ed7f0..4740bb17e55a95282be9ba53e49697e38b44e7e3 100644 (file)
@@ -18,7 +18,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
 const pkgJson = path.join(__dirname, '../package.json')
 const pkg = JSON.parse(await fs.readFile(pkgJson, 'utf8'))
 
-const versionShort = pkg.config.version_short
+const { versionShort } = pkg.bootstrap
 const distFolder = `bootstrap-${pkg.version}-examples`
 const rootDocsDir = '_site'
 const docsDir = `${rootDocsDir}/docs/${versionShort}/`
diff --git a/js/src/chip-input.js b/js/src/chip-input.js
new file mode 100644 (file)
index 0000000..925dd90
--- /dev/null
@@ -0,0 +1,633 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap chip-input.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import BaseComponent from './base-component.js'
+import EventHandler from './dom/event-handler.js'
+import SelectorEngine from './dom/selector-engine.js'
+
+/**
+ * Constants
+ */
+
+const NAME = 'chipInput'
+const DATA_KEY = 'bs.chip-input'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const EVENT_ADD = `add${EVENT_KEY}`
+const EVENT_REMOVE = `remove${EVENT_KEY}`
+const EVENT_CHANGE = `change${EVENT_KEY}`
+const EVENT_SELECT = `select${EVENT_KEY}`
+
+const SELECTOR_DATA_CHIP_INPUT = '[data-bs-chip-input]'
+const SELECTOR_GHOST_INPUT = '.form-ghost'
+const SELECTOR_CHIP = '.chip'
+const SELECTOR_CHIP_DISMISS = '.chip-dismiss'
+
+const CLASS_NAME_CHIP = 'chip'
+const CLASS_NAME_CHIP_DISMISS = 'chip-dismiss'
+const CLASS_NAME_ACTIVE = 'active'
+
+const DEFAULT_DISMISS_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>'
+
+const Default = {
+  separator: ',',
+  allowDuplicates: false,
+  maxChips: null,
+  placeholder: '',
+  dismissible: true,
+  dismissIcon: DEFAULT_DISMISS_ICON,
+  createOnBlur: true
+}
+
+const DefaultType = {
+  separator: '(string|null)',
+  allowDuplicates: 'boolean',
+  maxChips: '(number|null)',
+  placeholder: 'string',
+  dismissible: 'boolean',
+  dismissIcon: 'string',
+  createOnBlur: 'boolean'
+}
+
+/**
+ * Class definition
+ */
+
+class ChipInput extends BaseComponent {
+  constructor(element, config) {
+    super(element, config)
+
+    this._input = SelectorEngine.findOne(SELECTOR_GHOST_INPUT, this._element)
+    this._chips = []
+    this._selectedChips = new Set()
+    this._anchorChip = null // For shift+click range selection
+
+    if (!this._input) {
+      this._createInput()
+    }
+
+    this._initializeExistingChips()
+    this._addEventListeners()
+  }
+
+  // Getters
+  static get Default() {
+    return Default
+  }
+
+  static get DefaultType() {
+    return DefaultType
+  }
+
+  static get NAME() {
+    return NAME
+  }
+
+  // Public
+  add(value) {
+    const trimmedValue = String(value).trim()
+
+    if (!trimmedValue) {
+      return null
+    }
+
+    // Check for duplicates
+    if (!this._config.allowDuplicates && this._chips.includes(trimmedValue)) {
+      return null
+    }
+
+    // Check max chips limit
+    if (this._config.maxChips !== null && this._chips.length >= this._config.maxChips) {
+      return null
+    }
+
+    const addEvent = EventHandler.trigger(this._element, EVENT_ADD, {
+      value: trimmedValue,
+      relatedTarget: this._input
+    })
+
+    if (addEvent.defaultPrevented) {
+      return null
+    }
+
+    const chip = this._createChip(trimmedValue)
+    this._element.insertBefore(chip, this._input)
+    this._chips.push(trimmedValue)
+
+    EventHandler.trigger(this._element, EVENT_CHANGE, {
+      values: this.getValues()
+    })
+
+    return chip
+  }
+
+  remove(chipOrValue) {
+    let chip
+    let value
+
+    if (typeof chipOrValue === 'string') {
+      value = chipOrValue
+      chip = this._findChipByValue(value)
+    } else {
+      chip = chipOrValue
+      value = this._getChipValue(chip)
+    }
+
+    if (!chip || !value) {
+      return false
+    }
+
+    const removeEvent = EventHandler.trigger(this._element, EVENT_REMOVE, {
+      value,
+      chip,
+      relatedTarget: this._input
+    })
+
+    if (removeEvent.defaultPrevented) {
+      return false
+    }
+
+    // Remove from selection
+    this._selectedChips.delete(chip)
+    if (this._anchorChip === chip) {
+      this._anchorChip = null
+    }
+
+    // Remove from DOM and array
+    chip.remove()
+    this._chips = this._chips.filter(v => v !== value)
+
+    EventHandler.trigger(this._element, EVENT_CHANGE, {
+      values: this.getValues()
+    })
+
+    return true
+  }
+
+  removeSelected() {
+    const chipsToRemove = [...this._selectedChips]
+    for (const chip of chipsToRemove) {
+      this.remove(chip)
+    }
+
+    this._input?.focus()
+  }
+
+  getValues() {
+    return [...this._chips]
+  }
+
+  getSelectedValues() {
+    return [...this._selectedChips].map(chip => this._getChipValue(chip))
+  }
+
+  clear() {
+    const chips = SelectorEngine.find(SELECTOR_CHIP, this._element)
+    for (const chip of chips) {
+      chip.remove()
+    }
+
+    this._chips = []
+    this._selectedChips.clear()
+    this._anchorChip = null
+
+    EventHandler.trigger(this._element, EVENT_CHANGE, {
+      values: []
+    })
+  }
+
+  clearSelection() {
+    for (const chip of this._selectedChips) {
+      chip.classList.remove(CLASS_NAME_ACTIVE)
+    }
+
+    this._selectedChips.clear()
+    this._anchorChip = null
+
+    EventHandler.trigger(this._element, EVENT_SELECT, {
+      selected: []
+    })
+  }
+
+  selectChip(chip, options = {}) {
+    const { addToSelection = false, rangeSelect = false } = options
+    const chipElements = this._getChipElements()
+
+    if (!chipElements.includes(chip)) {
+      return
+    }
+
+    if (rangeSelect && this._anchorChip) {
+      // Range selection from anchor to chip
+      const anchorIndex = chipElements.indexOf(this._anchorChip)
+      const chipIndex = chipElements.indexOf(chip)
+      const start = Math.min(anchorIndex, chipIndex)
+      const end = Math.max(anchorIndex, chipIndex)
+
+      if (!addToSelection) {
+        this.clearSelection()
+      }
+
+      for (let i = start; i <= end; i++) {
+        this._selectedChips.add(chipElements[i])
+        chipElements[i].classList.add(CLASS_NAME_ACTIVE)
+      }
+    } else if (addToSelection) {
+      // Toggle selection
+      if (this._selectedChips.has(chip)) {
+        this._selectedChips.delete(chip)
+        chip.classList.remove(CLASS_NAME_ACTIVE)
+      } else {
+        this._selectedChips.add(chip)
+        chip.classList.add(CLASS_NAME_ACTIVE)
+        this._anchorChip = chip
+      }
+    } else {
+      // Single selection
+      this.clearSelection()
+      this._selectedChips.add(chip)
+      chip.classList.add(CLASS_NAME_ACTIVE)
+      this._anchorChip = chip
+    }
+
+    EventHandler.trigger(this._element, EVENT_SELECT, {
+      selected: this.getSelectedValues()
+    })
+  }
+
+  focus() {
+    this._input?.focus()
+  }
+
+  // Private
+  _getChipElements() {
+    return SelectorEngine.find(SELECTOR_CHIP, this._element)
+  }
+
+  _createInput() {
+    const input = document.createElement('input')
+    input.type = 'text'
+    input.className = 'form-ghost'
+    if (this._config.placeholder) {
+      input.placeholder = this._config.placeholder
+    }
+
+    this._element.append(input)
+    this._input = input
+  }
+
+  _initializeExistingChips() {
+    const existingChips = SelectorEngine.find(SELECTOR_CHIP, this._element)
+    for (const chip of existingChips) {
+      const value = this._getChipValue(chip)
+      if (value) {
+        this._chips.push(value)
+        this._setupChip(chip)
+      }
+    }
+  }
+
+  _setupChip(chip) {
+    // Make chip focusable
+    chip.setAttribute('tabindex', '0')
+
+    // Add dismiss button if needed
+    if (this._config.dismissible && !SelectorEngine.findOne(SELECTOR_CHIP_DISMISS, chip)) {
+      chip.append(this._createDismissButton())
+    }
+  }
+
+  _createChip(value) {
+    const chip = document.createElement('span')
+    chip.className = CLASS_NAME_CHIP
+    chip.dataset.bsChipValue = value
+
+    // Add text node
+    chip.append(document.createTextNode(value))
+
+    // Setup chip (tabindex, dismiss button)
+    this._setupChip(chip)
+
+    return chip
+  }
+
+  _createDismissButton() {
+    const button = document.createElement('button')
+    button.type = 'button'
+    button.className = CLASS_NAME_CHIP_DISMISS
+    button.setAttribute('aria-label', 'Remove')
+    button.setAttribute('tabindex', '-1') // Not in tab order, chips handle keyboard
+    button.innerHTML = this._config.dismissIcon
+    return button
+  }
+
+  _findChipByValue(value) {
+    const chips = this._getChipElements()
+    return chips.find(chip => this._getChipValue(chip) === value)
+  }
+
+  _getChipValue(chip) {
+    if (chip.dataset.bsChipValue) {
+      return chip.dataset.bsChipValue
+    }
+
+    const clone = chip.cloneNode(true)
+    const dismiss = SelectorEngine.findOne(SELECTOR_CHIP_DISMISS, clone)
+    if (dismiss) {
+      dismiss.remove()
+    }
+
+    return clone.textContent?.trim() || ''
+  }
+
+  _addEventListeners() {
+    // Input events
+    EventHandler.on(this._input, 'keydown', event => this._handleInputKeydown(event))
+    EventHandler.on(this._input, 'input', event => this._handleInput(event))
+    EventHandler.on(this._input, 'paste', event => this._handlePaste(event))
+    EventHandler.on(this._input, 'focus', () => this.clearSelection())
+
+    if (this._config.createOnBlur) {
+      EventHandler.on(this._input, 'blur', event => {
+        // Don't create chip if clicking on a chip
+        if (!event.relatedTarget?.closest(SELECTOR_CHIP)) {
+          this._createChipFromInput()
+        }
+      })
+    }
+
+    // Chip click events (delegated)
+    EventHandler.on(this._element, 'click', SELECTOR_CHIP, event => {
+      // Ignore clicks on dismiss button
+      if (event.target.closest(SELECTOR_CHIP_DISMISS)) {
+        return
+      }
+
+      const chip = event.target.closest(SELECTOR_CHIP)
+      if (chip) {
+        event.preventDefault()
+        this.selectChip(chip, {
+          addToSelection: event.metaKey || event.ctrlKey,
+          rangeSelect: event.shiftKey
+        })
+        chip.focus()
+      }
+    })
+
+    // Dismiss button clicks (delegated)
+    EventHandler.on(this._element, 'click', SELECTOR_CHIP_DISMISS, event => {
+      event.stopPropagation()
+      const chip = event.target.closest(SELECTOR_CHIP)
+      if (chip) {
+        this.remove(chip)
+        this._input?.focus()
+      }
+    })
+
+    // Chip keyboard events (delegated)
+    EventHandler.on(this._element, 'keydown', SELECTOR_CHIP, event => {
+      this._handleChipKeydown(event)
+    })
+
+    // Focus input when clicking container background
+    EventHandler.on(this._element, 'click', event => {
+      if (event.target === this._element) {
+        this.clearSelection()
+        this._input?.focus()
+      }
+    })
+  }
+
+  _handleInputKeydown(event) {
+    const { key } = event
+
+    switch (key) {
+      case 'Enter': {
+        event.preventDefault()
+        this._createChipFromInput()
+        break
+      }
+
+      case 'Backspace':
+      case 'Delete': {
+        if (this._input.value === '') {
+          event.preventDefault()
+          const chips = this._getChipElements()
+
+          if (chips.length > 0) {
+            // Select last chip and focus it
+            const lastChip = chips.at(-1)
+            this.selectChip(lastChip)
+            lastChip.focus()
+          }
+        }
+
+        break
+      }
+
+      case 'ArrowLeft': {
+        if (this._input.selectionStart === 0 && this._input.selectionEnd === 0) {
+          event.preventDefault()
+          const chips = this._getChipElements()
+          if (chips.length > 0) {
+            const lastChip = chips.at(-1)
+            if (event.shiftKey) {
+              this.selectChip(lastChip, { addToSelection: true })
+            } else {
+              this.selectChip(lastChip)
+            }
+
+            lastChip.focus()
+          }
+        }
+
+        break
+      }
+
+      case 'Escape': {
+        this._input.value = ''
+        this.clearSelection()
+        this._input.blur()
+        break
+      }
+
+      // No default
+    }
+  }
+
+  _handleChipKeydown(event) {
+    const { key } = event
+    const chip = event.target.closest(SELECTOR_CHIP)
+    if (!chip) {
+      return
+    }
+
+    const chips = this._getChipElements()
+    const currentIndex = chips.indexOf(chip)
+
+    switch (key) {
+      case 'Backspace':
+      case 'Delete': {
+        event.preventDefault()
+        this._handleChipDelete(currentIndex, chips)
+        break
+      }
+
+      case 'ArrowLeft': {
+        event.preventDefault()
+        this._navigateChip(chips, currentIndex, -1, event.shiftKey)
+        break
+      }
+
+      case 'ArrowRight': {
+        event.preventDefault()
+        this._navigateChip(chips, currentIndex, 1, event.shiftKey)
+        break
+      }
+
+      case 'Home': {
+        event.preventDefault()
+        this._navigateToEdge(chips, 0, event.shiftKey)
+        break
+      }
+
+      case 'End': {
+        event.preventDefault()
+        this.clearSelection()
+        this._input?.focus()
+        break
+      }
+
+      case 'a': {
+        this._handleSelectAll(event, chips)
+        break
+      }
+
+      case 'Escape': {
+        event.preventDefault()
+        this.clearSelection()
+        this._input?.focus()
+        break
+      }
+
+      // No default
+    }
+  }
+
+  _handleChipDelete(currentIndex, chips) {
+    if (this._selectedChips.size === 0) {
+      return
+    }
+
+    const nextIndex = Math.min(currentIndex, chips.length - this._selectedChips.size - 1)
+    this.removeSelected()
+
+    const remainingChips = this._getChipElements()
+    if (remainingChips.length > 0) {
+      const focusIndex = Math.max(0, Math.min(nextIndex, remainingChips.length - 1))
+      remainingChips[focusIndex].focus()
+      this.selectChip(remainingChips[focusIndex])
+    } else {
+      this._input?.focus()
+    }
+  }
+
+  _navigateChip(chips, currentIndex, direction, shiftKey) {
+    const targetIndex = currentIndex + direction
+
+    if (direction < 0 && targetIndex >= 0) {
+      const targetChip = chips[targetIndex]
+      this.selectChip(targetChip, shiftKey ? { addToSelection: true, rangeSelect: true } : {})
+      targetChip.focus()
+    } else if (direction > 0 && targetIndex < chips.length) {
+      const targetChip = chips[targetIndex]
+      this.selectChip(targetChip, shiftKey ? { addToSelection: true, rangeSelect: true } : {})
+      targetChip.focus()
+    } else if (direction > 0) {
+      this.clearSelection()
+      this._input?.focus()
+    }
+  }
+
+  _navigateToEdge(chips, targetIndex, shiftKey) {
+    if (chips.length === 0) {
+      return
+    }
+
+    const targetChip = chips[targetIndex]
+    this.selectChip(targetChip, shiftKey ? { rangeSelect: true } : {})
+    targetChip.focus()
+  }
+
+  _handleSelectAll(event, chips) {
+    if (!(event.metaKey || event.ctrlKey)) {
+      return
+    }
+
+    event.preventDefault()
+    for (const c of chips) {
+      this._selectedChips.add(c)
+      c.classList.add(CLASS_NAME_ACTIVE)
+    }
+
+    EventHandler.trigger(this._element, EVENT_SELECT, {
+      selected: this.getSelectedValues()
+    })
+  }
+
+  _handleInput(event) {
+    const { value } = event.target
+    const { separator } = this._config
+
+    if (separator && value.includes(separator)) {
+      const parts = value.split(separator)
+      for (const part of parts.slice(0, -1)) {
+        this.add(part.trim())
+      }
+
+      this._input.value = parts.at(-1)
+    }
+  }
+
+  _handlePaste(event) {
+    const { separator } = this._config
+    if (!separator) {
+      return
+    }
+
+    const pastedData = (event.clipboardData || window.clipboardData).getData('text')
+    if (pastedData.includes(separator)) {
+      event.preventDefault()
+
+      const parts = pastedData.split(separator)
+      for (const part of parts) {
+        this.add(part.trim())
+      }
+    }
+  }
+
+  _createChipFromInput() {
+    const value = this._input.value.trim()
+    if (value) {
+      this.add(value)
+      this._input.value = ''
+    }
+  }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => {
+  for (const element of SelectorEngine.find(SELECTOR_DATA_CHIP_INPUT)) {
+    ChipInput.getOrCreateInstance(element)
+  }
+})
+
+export default ChipInput
index e9df48086f61a135faa85d93f82b87cba5c6c47c..ab5fcaee54cd925f369fd715c614dc48328f55e1 100644 (file)
@@ -2,8 +2,8 @@
   "name": "bootstrap",
   "description": "The most popular front-end framework for developing responsive, mobile first projects on the web.",
   "version": "5.3.8",
-  "config": {
-    "version_short": "5.3"
+  "bootstrap": {
+    "versionShort": "5.3"
   },
   "keywords": [
     "css",
diff --git a/scss/_chip.scss b/scss/_chip.scss
new file mode 100644 (file)
index 0000000..6e761f3
--- /dev/null
@@ -0,0 +1,162 @@
+@use "sass:map";
+@use "colors" as *;
+@use "variables" as *;
+@use "theme" as *;
+@use "mixins/border-radius" as *;
+@use "mixins/focus-ring" as *;
+
+// scss-docs-start chip-variables
+// $chip-font-size:                    .875em !default;
+// $chip-font-weight:                  $font-weight-normal !default;
+// $chip-padding-y:                    .25rem !default;
+$chip-height:                       1.75rem !default;
+$chip-padding-x:                    .625rem !default;
+$chip-gap:                          .3125rem !default;
+$chip-border-radius:                var(--border-radius-pill) !default;
+$chip-icon-size:                    1rem !default;
+$chip-img-size:                     1.25rem !default;
+$chip-dismiss-size:                 1rem !default;
+$chip-dismiss-opacity:              .65 !default;
+$chip-dismiss-hover-opacity:        1 !default;
+// scss-docs-end chip-variables
+
+@layer components {
+  .chip {
+    // scss-docs-start chip-css-vars
+    // --chip-padding-y: #{$chip-padding-y};
+    --chip-height: #{$chip-height};
+    --chip-padding-x: #{$chip-padding-x};
+    --chip-gap: #{$chip-gap};
+    // --chip-font-size: #{$chip-font-size};
+    // --chip-font-weight: #{$chip-font-weight};
+    --chip-border-radius: #{$chip-border-radius};
+    --chip-img-size: #{$chip-img-size};
+    --chip-icon-size: #{$chip-icon-size};
+    --chip-dismiss-size: #{$chip-dismiss-size};
+    --chip-dismiss-opacity: #{$chip-dismiss-opacity};
+    --chip-dismiss-hover-opacity: #{$chip-dismiss-hover-opacity};
+
+    // Default (subtle) appearance - uses theme when available
+    --chip-color: var(--theme-text, var(--fg-body));
+    --chip-bg: var(--theme-bg-subtle, var(--bg-2));
+    --chip-border-color: transparent;
+
+    // Selected/active state colors (solid appearance)
+    --chip-selected-color: var(--theme-contrast, var(--primary-contrast));
+    --chip-selected-bg: var(--theme-bg, var(--primary-bg));
+    --chip-selected-border-color: var(--theme-bg, var(--primary-bg));
+    // scss-docs-end chip-css-vars
+
+    display: inline-flex;
+    gap: var(--chip-gap);
+    align-items: center;
+    height: var(--chip-height);
+    padding-inline: var(--chip-padding-x);
+    // font-size: var(--chip-font-size);
+    // font-weight: var(--chip-font-weight);
+    // line-height: 1.25;
+    color: var(--chip-color);
+    text-decoration: none;
+    white-space: nowrap;
+    vertical-align: middle;
+    cursor: pointer;
+    background-color: var(--chip-bg);
+    border: var(--border-width) solid var(--chip-border-color);
+    @include border-radius(var(--chip-border-radius));
+
+    &:hover {
+      --chip-bg: var(--theme-bg-muted, var(--bg-3));
+    }
+
+    &:focus-visible {
+      outline: 0;
+      // @include focus-ring();
+    }
+
+    // Active/selected state - solid appearance
+    &.active {
+      --chip-color: var(--chip-selected-color);
+      --chip-bg: var(--chip-selected-bg);
+      --chip-border-color: var(--chip-selected-border-color);
+
+      &:hover {
+        --chip-bg: var(--chip-selected-bg);
+        opacity: .9;
+      }
+    }
+
+    // Disabled state
+    &.disabled,
+    &:disabled {
+      pointer-events: none;
+      opacity: .65;
+    }
+  }
+
+  .chip-img {
+    width: var(--chip-img-size);
+    height: var(--chip-img-size);
+    @include border-radius(50%);
+
+    &:first-child {
+      margin-inline-start: -.375rem;
+    }
+  }
+
+  // Chip icon (left side)
+  .chip-icon {
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
+    margin-inline-start: calc(var(--chip-gap) * -.25);
+
+    > svg {
+      display: block; // Prevents baseline alignment issues
+      width: var(--chip-icon-size);
+      height: var(--chip-icon-size);
+    }
+
+    > img {
+      width: var(--chip-icon-size);
+      height: var(--chip-icon-size);
+      object-fit: cover;
+      @include border-radius(50%);
+    }
+  }
+
+  // Dismiss button (right side)
+  .chip-dismiss {
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
+    width: var(--chip-min-height);
+    height: var(--chip-min-height);
+    padding: 0;
+    // margin-inline-start: calc(var(--chip-padding-x) * -.5);
+    margin-inline-end: calc(var(--chip-padding-x) * -.25);
+    color: inherit;
+    cursor: pointer;
+    background: transparent;
+    border: 0;
+    opacity: var(--chip-dismiss-opacity);
+    // @include transition(opacity .15s ease-in-out);
+
+    &:hover {
+      opacity: var(--chip-dismiss-hover-opacity);
+    }
+
+    &:focus-visible {
+      outline: 0;
+      opacity: 1;
+      @include focus-ring();
+    }
+
+    > svg {
+      display: block; // Prevents baseline alignment issues
+      width: var(--chip-dismiss-size);
+      height: var(--chip-dismiss-size);
+    }
+  }
+}
index 167095d0639561fea2c56915f9f7aa25d8a656a4..441b6609b286dd4c2657a094b65ce5ec1c06693f 100644 (file)
@@ -16,6 +16,7 @@
 @forward "avatar";
 @forward "badge";
 @forward "breadcrumb";
+@forward "chip";
 @forward "card";
 @forward "carousel";
 @forward "datepicker";
diff --git a/scss/forms/_chip-input.scss b/scss/forms/_chip-input.scss
new file mode 100644 (file)
index 0000000..c0b04ba
--- /dev/null
@@ -0,0 +1,122 @@
+@use "../config" as *;
+@use "../variables" as *;
+@use "../theme" as *;
+@use "../mixins/border-radius" as *;
+@use "../mixins/box-shadow" as *;
+@use "../mixins/focus-ring" as *;
+@use "../mixins/transition" as *;
+@use "form-variables" as *;
+
+// scss-docs-start chip-input-variables
+$chip-input-gap:                    .375rem !default;
+$chip-input-padding-y:              .75rem !default;
+$chip-input-padding-x:              .75rem !default;
+$chip-input-chip-padding-y:         .75rem !default;
+$chip-input-chip-padding-x:         .5rem !default;
+$chip-input-ghost-min-width:        5rem !default;
+// scss-docs-end chip-input-variables
+
+@layer forms {
+  .chip-input {
+    // Inherit form-control CSS variables for sizing
+    // --control-min-height: #{$control-min-height};
+    --chip-input-padding-y: #{$chip-input-padding-y};
+    --chip-input-padding-x: #{$chip-input-padding-x};
+    --control-padding-x: #{$control-padding-x};
+    --control-color: #{$control-color};
+    --control-bg: #{$control-bg};
+    --control-border-width: #{$control-border-width};
+    --control-border-color: #{$control-border-color};
+    --control-border-radius: #{$control-border-radius};
+
+    // Chip input specific
+    --chip-input-gap: #{$chip-input-gap};
+    // --chip-input-chip-padding-y: #{$chip-input-chip-padding-y};
+    // --chip-input-chip-padding-x: #{$chip-input-chip-padding-x};
+    // --chip-input-chip-font-size: #{$chip-input-chip-font-size};
+    --chip-input-ghost-min-width: #{$chip-input-ghost-min-width};
+
+    // Flexbox wrapping layout
+    display: flex;
+    flex-wrap: wrap;
+    gap: var(--chip-input-gap);
+    align-items: center;
+    padding: var(--chip-input-padding-y) var(--chip-input-padding-x);
+
+    color: var(--control-color);
+    background-color: var(--control-bg);
+    border: var(--control-border-width) solid var(--control-border-color);
+    @include border-radius(var(--control-border-radius), 0);
+
+    // Focus state when ghost input is focused
+    &:focus-within {
+      --focus-ring-offset: -1px;
+      border-color: $input-focus-border-color;
+      @include focus-ring(true);
+    }
+
+    // Ghost input fills remaining space
+    > .form-ghost {
+      flex: 1 1 0;
+      min-width: var(--chip-input-ghost-min-width);
+      min-height: 1.75rem;
+    }
+
+    // Disabled state
+    &.disabled,
+    &:has(.form-ghost:disabled) {
+      background-color: $input-disabled-bg;
+      opacity: 1;
+
+      > .chip {
+        opacity: .65;
+
+        .chip-dismiss {
+          pointer-events: none;
+        }
+      }
+
+      > .form-ghost {
+        cursor: not-allowed;
+      }
+    }
+  }
+
+  // Theme cascade: .chip-input.theme-* passes theme to child chips
+  // Chips inherit theme variables from parent
+  // @each $color-name, $theme-props in $theme-map {
+  //   .chip-input.theme-#{$color-name} > .chip {
+  //     // Subtle default state
+  //     --chip-color: var(--theme-text);
+  //     --chip-bg: var(--theme-bg-subtle);
+
+  //     // Selected/active solid state
+  //     --chip-selected-color: var(--theme-contrast);
+  //     --chip-selected-bg: var(--theme-bg);
+  //     --chip-selected-border-color: var(--theme-bg);
+  //   }
+  // }
+
+  // // Sizing variants
+  // .chip-input-sm {
+  //   --control-min-height: #{$control-min-height-sm};
+  //   --control-padding-y: #{$control-padding-y-sm};
+  //   --control-padding-x: #{$control-padding-x-sm};
+  //   --control-font-size: #{$control-font-size-sm};
+  //   --control-line-height: #{$control-line-height-sm};
+  //   --control-border-radius: #{$control-border-radius-sm};
+  //   --chip-input-gap: .25rem;
+  //   --chip-input-chip-font-size: .8125em;
+  // }
+
+  // .chip-input-lg {
+  //   --control-min-height: #{$control-min-height-lg};
+  //   --control-padding-y: #{$control-padding-y-lg};
+  //   --control-padding-x: #{$control-padding-x-lg};
+  //   --control-font-size: #{$control-font-size-lg};
+  //   --control-line-height: #{$control-line-height-lg};
+  //   --control-border-radius: #{$control-border-radius-lg};
+  //   --chip-input-gap: .5rem;
+  //   --chip-input-chip-font-size: .9375em;
+  // }
+}
index 4817b882c1aab34dd3d3844c9228061edcc1353d..1b4c96116ddcca858a15da4c724c850d2bdd593a 100644 (file)
@@ -10,4 +10,5 @@
 @forward "strength";
 @forward "otp-input";
 @forward "form-adorn";
+@forward "chip-input";
 @forward "validation";
index 3bb8358d07a0814633aa53757a5973e6ca2a19a4..7f8c66bcabeea7d06d91d1e43d9aa1cbaad19950 100644 (file)
@@ -75,6 +75,7 @@
     - title: Floating labels
     - title: OTP input
     - title: Password strength
+    - title: Chip input
     - title: Form adorn
     - title: Layout
     - title: Validation
diff --git a/site/src/content/docs/forms/chip-input.mdx b/site/src/content/docs/forms/chip-input.mdx
new file mode 100644 (file)
index 0000000..1e6dbbf
--- /dev/null
@@ -0,0 +1,338 @@
+---
+title: Chip input
+description: Create tag-like inputs for multi-value fields like skills, categories, or email recipients using themed chips.
+toc: true
+---
+
+import { getData } from '@libs/data'
+
+## Overview
+
+Chips are similar to badges, but they have a single size and more defined visual styles useful for indicating state and selection.
+
+- Chips are statically sized—they don't scale with the parent element by default.
+- Chips can have icons, avatars, and dismiss buttons.
+- Chips can be themed, individually or as a group on the parent container. By default, chips use the `subtle` and `text` theme tokens, while active state uses the `bg` and `contrast` tokens.
+- Chips can be active or disabled.
+- Chips automatically gain focus when clicked into their active state.
+- Chips support keyboard navigation and selection in their custom `.chip-input` container.
+
+See examples of all of this in action below.
+
+## Basic chips
+
+Use `.chip` for standalone chips. Add `.chip-icon` for a leading icon, `.chip-img` for an image like an avatar, and `.chip-dismiss` for a remove button. Note that we use an inline SVG for the dismiss button icon—you can modify this as needed. JavaScript users can use the `dismissIcon` option for passing a custom SVG.
+
+<Example code={`<span class="chip">Basic chip</span>
+
+  <span class="chip">
+    <img src="https://github.com/mdo.png" class="chip-img" width="16" height="16" alt="">
+    With avatar
+  </span>
+
+  <span class="chip">
+    <span class="chip-icon">
+      <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
+        <path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
+      </svg>
+    </span>
+    With icon
+  </span>
+
+  <span class="chip">
+    Dismissible
+    <button type="button" class="chip-dismiss" aria-label="Remove">
+      <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>
+    </button>
+  </span>`} />
+
+### Themed chips
+
+Apply `.theme-*` classes to color your chips. Chips are subtle by default as this allows for a clear, themed active state.
+
+<Example code={[
+  `<span class="chip">Default</span>`,
+  ...getData('theme-colors').map((themeColor) => `<span class="chip theme-${themeColor.name}">${themeColor.title}</span>`)
+]} />
+
+### Active state
+
+Add `.active` to make chips use the solid appearance (bg/contrast). This is useful for toggle-style chip selections.
+
+<Example code={`<span class="chip theme-primary">Default</span>
+  <span class="chip theme-primary active">Active</span>
+  <span class="chip theme-success">Default</span>
+  <span class="chip theme-success active">Active</span>`} />
+
+## Chip input
+
+Wrap chips and a ghost input in `.chip-input` to create a tag input field. Add `data-bs-chip-input` to enable JavaScript behavior. Chips are grayscale by default, with primary blue for active states, but you can apply any theme color to the container so that chips within inherit the color of your choosing.
+
+<Example code={`<div class="chip-input" data-bs-chip-input>
+    <label class="chip-input-label" for="skillsInput">Skills:</label>
+    <span class="chip">
+      JavaScript
+    </span>
+    <span class="chip">
+      TypeScript
+    </span>
+    <input type="text" class="form-ghost" id="skillsInput" placeholder="Add more…" />
+  </div>`} />
+
+### Theme variants
+
+Add a `.theme-*` class to the `.chip-input` container to apply a theme color to all chips within.
+
+<Example class="vstack gap-3" code={`<div class="chip-input theme-primary" data-bs-chip-input>
+    <span class="chip">Approved</span>
+    <span class="chip">Verified</span>
+    <input type="text" class="form-ghost" placeholder="Add status...">
+  </div>
+
+  <div class="chip-input theme-danger" data-bs-chip-input>
+    <span class="chip">Bug</span>
+    <span class="chip">Critical</span>
+    <input type="text" class="form-ghost" placeholder="Add issue">
+  </div>`} />
+
+You can also individually apply a theme color to a chip by adding a `.theme-*` class to the chip element.
+
+<Example code={`<div class="chip-input theme-primary" data-bs-chip-input>
+    <span class="chip theme-warning">Bug</span>
+    <span class="chip theme-danger">Critical</span>
+    <input type="text" class="form-ghost" placeholder="Add issue">
+  </div>`} />
+
+### Empty state
+
+Start with just the ghost input—chips are created as users type.
+
+<Example code={`<div class="chip-input theme-primary" data-bs-chip-input>
+    <input type="text" class="form-ghost" placeholder="Type and press Enter">
+  </div>`} />
+
+### With label
+
+Use a form label for better accessibility.
+
+<Example code={`<div class="mb-3">
+    <label class="form-label" for="skillsInputLabel">Skills</label>
+    <div class="chip-input theme-primary" data-bs-chip-input>
+      <span class="chip">
+        React
+        <button type="button" class="chip-dismiss" aria-label="Remove">
+          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>
+        </button>
+      </span>
+      <input type="text" class="form-ghost" id="skillsInputLabel" placeholder="Add skill...">
+    </div>
+    <div class="form-text">Press Enter or comma to add a skill.</div>
+  </div>`} />
+
+## Sizing
+
+Use `.chip-input-sm` or `.chip-input-lg` for different sizes.
+
+<Example class="vstack gap-3" code={`<div class="chip-input chip-input-sm theme-primary" data-bs-chip-input>
+    <span class="chip">
+      Small
+      <button type="button" class="chip-dismiss" aria-label="Remove">
+        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>
+      </button>
+    </span>
+    <input type="text" class="form-ghost" placeholder="Small...">
+  </div>
+
+  <div class="chip-input theme-primary" data-bs-chip-input>
+    <span class="chip">
+      Default
+      <button type="button" class="chip-dismiss" aria-label="Remove">
+        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>
+      </button>
+    </span>
+    <input type="text" class="form-ghost" placeholder="Default...">
+  </div>
+
+  <div class="chip-input chip-input-lg theme-primary" data-bs-chip-input>
+    <span class="chip">
+      Large
+      <button type="button" class="chip-dismiss" aria-label="Remove">
+        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>
+      </button>
+    </span>
+    <input type="text" class="form-ghost" placeholder="Large...">
+  </div>`} />
+
+## Disabled
+
+Disable the ghost input to prevent adding new chips. Existing chips become non-interactive.
+
+<Example code={`<div class="chip-input theme-primary" data-bs-chip-input>
+    <span class="chip">
+      Read only
+      <button type="button" class="chip-dismiss" aria-label="Remove">
+        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>
+      </button>
+    </span>
+    <input type="text" class="form-ghost" placeholder="Disabled..." disabled>
+  </div>`} />
+
+## Usage
+
+### Via data attributes
+
+Add `data-bs-chip-input` to your container element to automatically initialize the chip input behavior. Options can be passed as data attributes.
+
+```html
+<div class="chip-input theme-primary" data-bs-chip-input data-bs-separator="," data-bs-allow-duplicates="false">
+  <input type="text" class="form-ghost" placeholder="Add tags...">
+</div>
+```
+
+### Via JavaScript
+
+Initialize manually with JavaScript:
+
+```js
+const chipInputElement = document.querySelector('.chip-input')
+const chipInput = new bootstrap.ChipInput(chipInputElement, {
+  separator: ',',
+  maxChips: 5
+})
+```
+
+### Options
+
+Options can be passed via data attributes or JavaScript:
+
+| Name | Type | Default | Description |
+| --- | --- | --- | --- |
+| `separator` | string \| null | `','` | Character that triggers chip creation when typed. Set to `null` to disable. |
+| `allowDuplicates` | boolean | `false` | Allow duplicate chip values. |
+| `maxChips` | number \| null | `null` | Maximum number of chips allowed. `null` for unlimited. |
+| `placeholder` | string | `''` | Placeholder text for dynamically created inputs. |
+| `dismissible` | boolean | `true` | Add dismiss buttons to created chips. |
+| `dismissIcon` | string | `'<svg>...</svg>'` | HTML string for the dismiss button icon. |
+| `createOnBlur` | boolean | `true` | Create chip from input value when the input loses focus. |
+
+### Methods
+
+| Method | Description |
+| --- | --- |
+| `add(value)` | Adds a chip with the given value. Returns the chip element or `null` if rejected. |
+| `remove(chipOrValue)` | Removes a chip by element reference or value string. Returns `true` if removed. |
+| `removeSelected()` | Removes all currently selected chips. |
+| `getValues()` | Returns an array of all chip values. |
+| `getSelectedValues()` | Returns an array of selected chip values. |
+| `clear()` | Removes all chips. |
+| `clearSelection()` | Deselects all chips without removing them. |
+| `selectChip(chip, options)` | Selects a chip. Options: `addToSelection`, `rangeSelect`. |
+| `focus()` | Focuses the ghost input. |
+| `dispose()` | Destroys the component instance. |
+
+```js
+const chipInputElement = document.querySelector('.chip-input')
+const chipInput = bootstrap.ChipInput.getOrCreateInstance(chipInputElement)
+
+// Add chips programmatically
+chipInput.add('JavaScript')
+chipInput.add('TypeScript')
+
+// Get all values
+console.log(chipInput.getValues()) // ['JavaScript', 'TypeScript']
+
+// Remove a specific chip
+chipInput.remove('JavaScript')
+
+// Clear all chips
+chipInput.clear()
+```
+
+### Events
+
+| Event | Description |
+| --- | --- |
+| `add.bs.chip-input` | Fired before a chip is added. Call `event.preventDefault()` to cancel. |
+| `remove.bs.chip-input` | Fired before a chip is removed. Call `event.preventDefault()` to cancel. |
+| `change.bs.chip-input` | Fired after any chip is added or removed. Contains `values` array. |
+| `select.bs.chip-input` | Fired when chip selection changes. Contains `selected` array of values. |
+
+```js
+const chipInputElement = document.querySelector('.chip-input')
+
+chipInputElement.addEventListener('add.bs.chip-input', event => {
+  console.log('Adding chip:', event.value)
+
+  // Validate and optionally prevent
+  if (event.value.length < 2) {
+    event.preventDefault()
+  }
+})
+
+chipInputElement.addEventListener('remove.bs.chip-input', event => {
+  console.log('Removing chip:', event.value)
+})
+
+chipInputElement.addEventListener('change.bs.chip-input', event => {
+  console.log('Current values:', event.values)
+  // Sync with hidden input or state
+})
+
+chipInputElement.addEventListener('select.bs.chip-input', event => {
+  console.log('Selected:', event.selected)
+})
+```
+
+## Keyboard behavior
+
+Chip inputs support Mail.app-style keyboard navigation with chip selection.
+
+### When input is focused
+
+| Key | Action |
+| --- | --- |
+| `Enter` | Create chip from current input value |
+| `,` (or separator) | Create chip from current input value |
+| `Backspace` / `Delete` | When input is empty, select and focus last chip (first press); pressing again deletes it |
+| `←` | When cursor is at start, move focus to last chip |
+| `Shift+←` | Select last chip and extend selection |
+| `Escape` | Clear selection, clear input, and blur |
+
+### When a chip is focused
+
+| Key | Action |
+| --- | --- |
+| `Backspace` / `Delete` | Remove selected chips, then select and focus the next closest chip |
+| `←` | Move to previous chip |
+| `→` | Move to next chip (or input if at end) |
+| `Shift+←` / `Shift+→` | Extend selection in that direction |
+| `Home` | Move to first chip |
+| `End` | Move to input |
+| `Cmd/Ctrl+A` | Select all chips |
+| `Escape` | Clear selection and focus input |
+
+### Mouse selection
+
+| Action | Effect |
+| --- | --- |
+| Click chip | Select chip (deselects others) |
+| `Cmd/Ctrl+Click` | Toggle chip in selection |
+| `Shift+Click` | Range select from anchor to clicked chip |
+
+## Accessibility
+
+- Use a `<label>` element associated with the chip input for screen readers
+- Chips are focusable and support full keyboard navigation
+- Each `.chip-dismiss` button has `aria-label="Remove"`
+- Selected chips have the `.active` class for visual indication
+- Chips are announced as they are added or removed
+
+## CSS
+
+### Chip variables
+
+<ScssDocs name="chip-variables" file="scss/_chip.scss" />
+
+### Chip input variables
+
+<ScssDocs name="chip-input-variables" file="scss/forms/_chip-input.scss" />