From: Mark Otto Date: Tue, 20 Jan 2026 05:01:55 +0000 (-0800) Subject: Add new chip input and chips (#42008) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=d166d91682a23690cd0ea743df76cf00b1149383;p=thirdparty%2Fbootstrap.git Add new chip input and chips (#42008) * Add new chip input and chips * More fixes to build and linter * Bump bundlewatch * fix dupe id --- diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 6995d6a0aa..4ee0c4022e 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -26,11 +26,11 @@ }, { "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", diff --git a/build/zip-examples.mjs b/build/zip-examples.mjs index 6d71c2e10b..4740bb17e5 100644 --- a/build/zip-examples.mjs +++ b/build/zip-examples.mjs @@ -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 index 0000000000..925dd9068d --- /dev/null +++ b/js/src/chip-input.js @@ -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 = '' + +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 diff --git a/package.json b/package.json index e9df48086f..ab5fcaee54 100644 --- a/package.json +++ b/package.json @@ -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 index 0000000000..6e761f3363 --- /dev/null +++ b/scss/_chip.scss @@ -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); + } + } +} diff --git a/scss/bootstrap.scss b/scss/bootstrap.scss index 167095d063..441b6609b2 100644 --- a/scss/bootstrap.scss +++ b/scss/bootstrap.scss @@ -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 index 0000000000..c0b04baef4 --- /dev/null +++ b/scss/forms/_chip-input.scss @@ -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; + // } +} diff --git a/scss/forms/index.scss b/scss/forms/index.scss index 4817b882c1..1b4c96116d 100644 --- a/scss/forms/index.scss +++ b/scss/forms/index.scss @@ -10,4 +10,5 @@ @forward "strength"; @forward "otp-input"; @forward "form-adorn"; +@forward "chip-input"; @forward "validation"; diff --git a/site/data/sidebar.yml b/site/data/sidebar.yml index 3bb8358d07..7f8c66bcab 100644 --- a/site/data/sidebar.yml +++ b/site/data/sidebar.yml @@ -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 index 0000000000..1e6dbbf5cd --- /dev/null +++ b/site/src/content/docs/forms/chip-input.mdx @@ -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. + +Basic chip + + + + With avatar + + + + + + + + + With icon + + + + Dismissible + + `} /> + +### Themed chips + +Apply `.theme-*` classes to color your chips. Chips are subtle by default as this allows for a clear, themed active state. + +Default`, + ...getData('theme-colors').map((themeColor) => `${themeColor.title}`) +]} /> + +### Active state + +Add `.active` to make chips use the solid appearance (bg/contrast). This is useful for toggle-style chip selections. + +Default + Active + Default + Active`} /> + +## 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. + + + + + JavaScript + + + TypeScript + + + `} /> + +### Theme variants + +Add a `.theme-*` class to the `.chip-input` container to apply a theme color to all chips within. + + + Approved + Verified + + + +
+ Bug + Critical + +
`} /> + +You can also individually apply a theme color to a chip by adding a `.theme-*` class to the chip element. + + + Bug + Critical + + `} /> + +### Empty state + +Start with just the ghost input—chips are created as users type. + + + + `} /> + +### With label + +Use a form label for better accessibility. + + + +
+ + React + + + +
+
Press Enter or comma to add a skill.
+ `} /> + +## Sizing + +Use `.chip-input-sm` or `.chip-input-lg` for different sizes. + + + + Small + + + + + +
+ + Default + + + +
+ +
+ + Large + + + +
`} /> + +## Disabled + +Disable the ghost input to prevent adding new chips. Existing chips become non-interactive. + + + + Read only + + + + `} /> + +## 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 +
+ +
+``` + +### 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 | `'...'` | 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 `