]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Password strength plugin (#41980)
authorMark Otto <markd.otto@gmail.com>
Mon, 29 Dec 2025 22:47:01 +0000 (14:47 -0800)
committerMark Otto <markdotto@gmail.com>
Fri, 9 Jan 2026 04:14:09 +0000 (20:14 -0800)
* feat: add password strength component

- Add Strength JavaScript component with customizable scoring
- Add SCSS styles for strength meter and bar variants
- Add documentation page for password strength
- Add unit tests for strength component

* Bundle bump

* More bundle

.bundlewatch.config.json
js/index.esm.js
js/index.umd.js
js/src/strength.js [new file with mode: 0644]
js/tests/unit/strength.spec.js [new file with mode: 0644]
scss/forms/_strength.scss [new file with mode: 0644]
scss/forms/index.scss
site/data/sidebar.yml
site/src/content/docs/forms/password-strength.mdx [new file with mode: 0644]

index 90851acb4ee0f38bb536f09089016bf1e6ae76fd..f151d30c30d51fae608d97e61c8b806d188ecb1d 100644 (file)
     },
     {
       "path": "./dist/css/bootstrap.css",
-      "maxSize": "35.5 kB"
+      "maxSize": "35.75 kB"
     },
     {
       "path": "./dist/css/bootstrap.min.css",
-      "maxSize": "32.0 kB"
+      "maxSize": "32.25 kB"
     },
     {
       "path": "./dist/js/bootstrap.bundle.js",
-      "maxSize": "47.0 kB"
+      "maxSize": "48.5 kB"
     },
     {
       "path": "./dist/js/bootstrap.bundle.min.js",
-      "maxSize": "24.5 kB"
+      "maxSize": "25.25 kB"
     },
     {
       "path": "./dist/js/bootstrap.esm.js",
-      "maxSize": "33.5 kB"
+      "maxSize": "34.75 kB"
     },
     {
       "path": "./dist/js/bootstrap.esm.min.js",
-      "maxSize": "20.0 kB"
+      "maxSize": "21.0 kB"
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "34.0 kB"
+      "maxSize": "35.25 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
-      "maxSize": "18.25 kB"
+      "maxSize": "19.0 kB"
     }
   ],
   "ci": {
index e90572178272234c4620a82d58a6b8eb49976795..1c24edfe3fdef7a51c2fa46e1153fb421eb45ec0 100644 (file)
@@ -12,6 +12,7 @@ export { default as Collapse } from './src/collapse.js'
 export { default as Dialog } from './src/dialog.js'
 export { default as Dropdown } from './src/dropdown.js'
 export { default as Offcanvas } from './src/offcanvas.js'
+export { default as Strength } from './src/strength.js'
 export { default as Popover } from './src/popover.js'
 export { default as ScrollSpy } from './src/scrollspy.js'
 export { default as Tab } from './src/tab.js'
index a2dfece894f2c064cc207cfabc4a987332aa7ce6..eb0dc224af0dfc4eb176696fd1d6861d2463de75 100644 (file)
@@ -12,6 +12,7 @@ import Collapse from './src/collapse.js'
 import Dialog from './src/dialog.js'
 import Dropdown from './src/dropdown.js'
 import Offcanvas from './src/offcanvas.js'
+import Strength from './src/strength.js'
 import Popover from './src/popover.js'
 import ScrollSpy from './src/scrollspy.js'
 import Tab from './src/tab.js'
@@ -27,6 +28,7 @@ export default {
   Dialog,
   Dropdown,
   Offcanvas,
+  Strength,
   Popover,
   ScrollSpy,
   Tab,
diff --git a/js/src/strength.js b/js/src/strength.js
new file mode 100644 (file)
index 0000000..b46c04f
--- /dev/null
@@ -0,0 +1,261 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap strength.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 = 'strength'
+const DATA_KEY = 'bs.strength'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const EVENT_STRENGTH_CHANGE = `strengthChange${EVENT_KEY}`
+
+const SELECTOR_DATA_STRENGTH = '[data-bs-strength]'
+
+const STRENGTH_LEVELS = ['weak', 'fair', 'good', 'strong']
+
+const Default = {
+  input: null, // Selector or element for password input
+  minLength: 8,
+  messages: {
+    weak: 'Weak',
+    fair: 'Fair',
+    good: 'Good',
+    strong: 'Strong'
+  },
+  weights: {
+    minLength: 1,
+    extraLength: 1,
+    lowercase: 1,
+    uppercase: 1,
+    numbers: 1,
+    special: 1,
+    multipleSpecial: 1,
+    longPassword: 1
+  },
+  thresholds: [2, 4, 6], // weak ≤2, fair ≤4, good ≤6, strong >6
+  scorer: null // Custom scoring function (password) => number
+}
+
+const DefaultType = {
+  input: '(string|element|null)',
+  minLength: 'number',
+  messages: 'object',
+  weights: 'object',
+  thresholds: 'array',
+  scorer: '(function|null)'
+}
+
+/**
+ * Class definition
+ */
+
+class Strength extends BaseComponent {
+  constructor(element, config) {
+    super(element, config)
+
+    this._input = this._getInput()
+    this._segments = SelectorEngine.find('.strength-segment', this._element)
+    this._textElement = SelectorEngine.findOne('.strength-text', this._element.parentElement)
+    this._currentStrength = null
+
+    if (this._input) {
+      this._addEventListeners()
+      // Check initial value
+      this._evaluate()
+    }
+  }
+
+  // Getters
+  static get Default() {
+    return Default
+  }
+
+  static get DefaultType() {
+    return DefaultType
+  }
+
+  static get NAME() {
+    return NAME
+  }
+
+  // Public
+  getStrength() {
+    return this._currentStrength
+  }
+
+  evaluate() {
+    this._evaluate()
+  }
+
+  // Private
+  _getInput() {
+    if (this._config.input) {
+      return typeof this._config.input === 'string' ?
+        SelectorEngine.findOne(this._config.input) :
+        this._config.input
+    }
+
+    // Look for preceding password input
+    const parent = this._element.parentElement
+    return SelectorEngine.findOne('input[type="password"]', parent)
+  }
+
+  _addEventListeners() {
+    EventHandler.on(this._input, 'input', () => this._evaluate())
+    EventHandler.on(this._input, 'change', () => this._evaluate())
+  }
+
+  _evaluate() {
+    const password = this._input.value
+    const score = this._calculateScore(password)
+    const strength = this._scoreToStrength(score)
+
+    if (strength !== this._currentStrength) {
+      this._currentStrength = strength
+      this._updateUI(strength, score)
+
+      EventHandler.trigger(this._element, EVENT_STRENGTH_CHANGE, {
+        strength,
+        score,
+        password: password.length > 0 ? '***' : '' // Don't expose actual password
+      })
+    }
+  }
+
+  _calculateScore(password) {
+    if (!password) {
+      return 0
+    }
+
+    // Use custom scorer if provided
+    if (typeof this._config.scorer === 'function') {
+      return this._config.scorer(password)
+    }
+
+    const { weights } = this._config
+    let score = 0
+
+    // Length scoring
+    if (password.length >= this._config.minLength) {
+      score += weights.minLength
+    }
+
+    if (password.length >= this._config.minLength + 4) {
+      score += weights.extraLength
+    }
+
+    // Character variety
+    if (/[a-z]/.test(password)) {
+      score += weights.lowercase
+    }
+
+    if (/[A-Z]/.test(password)) {
+      score += weights.uppercase
+    }
+
+    if (/\d/.test(password)) {
+      score += weights.numbers
+    }
+
+    // Special characters
+    if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
+      score += weights.special
+    }
+
+    // Extra points for more special chars or length
+    if (/[!@#$%^&*(),.?":{}|<>].*[!@#$%^&*(),.?":{}|<>]/.test(password)) {
+      score += weights.multipleSpecial
+    }
+
+    if (password.length >= 16) {
+      score += weights.longPassword
+    }
+
+    return score
+  }
+
+  _scoreToStrength(score) {
+    if (score === 0) {
+      return null
+    }
+
+    const [weak, fair, good] = this._config.thresholds
+
+    if (score <= weak) {
+      return 'weak'
+    }
+
+    if (score <= fair) {
+      return 'fair'
+    }
+
+    if (score <= good) {
+      return 'good'
+    }
+
+    return 'strong'
+  }
+
+  _updateUI(strength) {
+    // Update data attribute on element
+    if (strength) {
+      this._element.dataset.bsStrength = strength
+    } else {
+      delete this._element.dataset.bsStrength
+    }
+
+    // Update segmented meter
+    const strengthIndex = strength ? STRENGTH_LEVELS.indexOf(strength) : -1
+
+    for (const [index, segment] of this._segments.entries()) {
+      if (index <= strengthIndex) {
+        segment.classList.add('active')
+      } else {
+        segment.classList.remove('active')
+      }
+    }
+
+    // Update text feedback
+    if (this._textElement) {
+      if (strength && this._config.messages[strength]) {
+        this._textElement.textContent = this._config.messages[strength]
+        this._textElement.dataset.bsStrength = strength
+
+        // Also set the color via inheriting from parent or using CSS variable
+        const colorMap = {
+          weak: 'danger',
+          fair: 'warning',
+          good: 'info',
+          strong: 'success'
+        }
+        this._textElement.style.setProperty('--strength-color', `var(--${colorMap[strength]}-text)`)
+      } else {
+        this._textElement.textContent = ''
+        delete this._textElement.dataset.bsStrength
+      }
+    }
+  }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => {
+  for (const element of SelectorEngine.find(SELECTOR_DATA_STRENGTH)) {
+    Strength.getOrCreateInstance(element)
+  }
+})
+
+export default Strength
diff --git a/js/tests/unit/strength.spec.js b/js/tests/unit/strength.spec.js
new file mode 100644 (file)
index 0000000..c79a389
--- /dev/null
@@ -0,0 +1,467 @@
+import Strength from '../../src/strength.js'
+import { clearFixture, createEvent, getFixture } from '../helpers/fixture.js'
+
+describe('Strength', () => {
+  let fixtureEl
+
+  beforeAll(() => {
+    fixtureEl = getFixture()
+  })
+
+  afterEach(() => {
+    clearFixture()
+  })
+
+  const getStrengthHtml = (options = {}) => {
+    const { segments = 4, withText = false, inputId = 'password' } = options
+    const segmentHtml = Array.from({ length: segments })
+      .map(() => '<div class="strength-segment"></div>')
+      .join('')
+    const textHtml = withText ? '<span class="strength-text"></span>' : ''
+
+    return `
+      <div>
+        <input type="password" id="${inputId}" class="form-control">
+        <div class="strength" data-bs-strength>
+          ${segmentHtml}
+        </div>
+        ${textHtml}
+      </div>
+    `
+  }
+
+  const getStrengthBarHtml = (inputId = 'password') => {
+    return `
+      <div>
+        <input type="password" id="${inputId}" class="form-control">
+        <div class="strength-bar" data-bs-strength></div>
+      </div>
+    `
+  }
+
+  describe('VERSION', () => {
+    it('should return plugin version', () => {
+      expect(Strength.VERSION).toEqual(jasmine.any(String))
+    })
+  })
+
+  describe('DATA_KEY', () => {
+    it('should return plugin data key', () => {
+      expect(Strength.DATA_KEY).toEqual('bs.strength')
+    })
+  })
+
+  describe('Default', () => {
+    it('should return default config', () => {
+      expect(Strength.Default).toEqual(jasmine.any(Object))
+      expect(Strength.Default.minLength).toEqual(8)
+      expect(Strength.Default.thresholds).toEqual([2, 4, 6])
+    })
+  })
+
+  describe('DefaultType', () => {
+    it('should return default type config', () => {
+      expect(Strength.DefaultType).toEqual(jasmine.any(Object))
+    })
+  })
+
+  describe('constructor', () => {
+    it('should take care of element either passed as a CSS selector or DOM element', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const strengthBySelector = new Strength('.strength')
+      const strengthByElement = new Strength(strengthEl)
+
+      expect(strengthBySelector._element).toEqual(strengthEl)
+      expect(strengthByElement._element).toEqual(strengthEl)
+    })
+
+    it('should find password input in parent container', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const strength = new Strength(strengthEl)
+
+      expect(strength._input).toEqual(passwordInput)
+    })
+
+    it('should use custom input selector', () => {
+      fixtureEl.innerHTML = `
+        <div>
+          <input type="password" id="other-password" class="form-control">
+        </div>
+        <div class="strength" data-bs-strength>
+          <div class="strength-segment"></div>
+          <div class="strength-segment"></div>
+          <div class="strength-segment"></div>
+          <div class="strength-segment"></div>
+        </div>
+      `
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const otherInput = fixtureEl.querySelector('#other-password')
+      const strength = new Strength(strengthEl, { input: '#other-password' })
+
+      expect(strength._input).toEqual(otherInput)
+    })
+  })
+
+  describe('getStrength', () => {
+    it('should return null initially', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const strength = new Strength(strengthEl)
+
+      expect(strength.getStrength()).toBeNull()
+    })
+
+    it('should return current strength level', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const strength = new Strength(strengthEl)
+
+      // Weak password - just meets min length
+      passwordInput.value = 'password'
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      expect(strength.getStrength()).toEqual('weak')
+    })
+  })
+
+  describe('evaluate', () => {
+    it('should manually trigger evaluation', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const strength = new Strength(strengthEl)
+
+      passwordInput.value = 'password123'
+      strength.evaluate()
+
+      expect(strength.getStrength()).not.toBeNull()
+    })
+  })
+
+  describe('scoring', () => {
+    it('should return weak for short passwords meeting min length', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const strength = new Strength(strengthEl)
+
+      passwordInput.value = 'password' // 8 chars, lowercase only = score 2
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      expect(strength.getStrength()).toEqual('weak')
+    })
+
+    it('should return fair for passwords with more variety', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const strength = new Strength(strengthEl)
+
+      passwordInput.value = 'Password1' // 9 chars, lower+upper+number = score 4
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      expect(strength.getStrength()).toEqual('fair')
+    })
+
+    it('should return good for strong passwords', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const strength = new Strength(strengthEl)
+
+      passwordInput.value = 'Password1!' // lower+upper+number+special+length = score 5-6
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      expect(strength.getStrength()).toEqual('good')
+    })
+
+    it('should return strong for very strong passwords', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const strength = new Strength(strengthEl)
+
+      // Long password with all criteria
+      passwordInput.value = 'MyStr0ngP@ssword!!'
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      expect(strength.getStrength()).toEqual('strong')
+    })
+
+    it('should return null for empty password', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const strength = new Strength(strengthEl)
+
+      passwordInput.value = ''
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      expect(strength.getStrength()).toBeNull()
+    })
+  })
+
+  describe('custom scorer', () => {
+    it('should use custom scoring function', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const customScorer = password => password.length >= 10 ? 8 : 1
+      const strength = new Strength(strengthEl, { scorer: customScorer })
+
+      passwordInput.value = 'short'
+      passwordInput.dispatchEvent(createEvent('input'))
+      expect(strength.getStrength()).toEqual('weak')
+
+      passwordInput.value = 'longenough'
+      passwordInput.dispatchEvent(createEvent('input'))
+      expect(strength.getStrength()).toEqual('strong')
+    })
+  })
+
+  describe('custom thresholds', () => {
+    it('should use custom threshold values', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const strength = new Strength(strengthEl, { thresholds: [1, 2, 3] })
+
+      // With low thresholds, even weak passwords rate higher
+      passwordInput.value = 'password'
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      // lowercase + minLength = 2, which is now "fair" with [1,2,3]
+      expect(strength.getStrength()).toEqual('fair')
+    })
+  })
+
+  describe('custom minLength', () => {
+    it('should use custom minimum length', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const strength = new Strength(strengthEl, { minLength: 12 })
+
+      passwordInput.value = 'password' // Only 8 chars, doesn't meet min
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      // Should be weak since it doesn't meet minLength requirement
+      expect(strength.getStrength()).toEqual('weak')
+    })
+  })
+
+  describe('UI updates', () => {
+    it('should set data-bs-strength attribute', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      new Strength(strengthEl) // eslint-disable-line no-new
+
+      passwordInput.value = 'password'
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      expect(strengthEl.dataset.bsStrength).toEqual('weak')
+    })
+
+    it('should remove data-bs-strength attribute when empty', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      new Strength(strengthEl) // eslint-disable-line no-new
+
+      passwordInput.value = 'password'
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      expect(strengthEl.dataset.bsStrength).toEqual('weak')
+
+      passwordInput.value = ''
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      expect(strengthEl.dataset.bsStrength).toBeUndefined()
+    })
+
+    it('should add active class to appropriate segments', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const segments = strengthEl.querySelectorAll('.strength-segment')
+      new Strength(strengthEl) // eslint-disable-line no-new
+
+      // Weak - 1 segment active
+      passwordInput.value = 'password'
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      expect(segments[0]).toHaveClass('active')
+      expect(segments[1]).not.toHaveClass('active')
+      expect(segments[2]).not.toHaveClass('active')
+      expect(segments[3]).not.toHaveClass('active')
+    })
+
+    it('should update text element with message', () => {
+      fixtureEl.innerHTML = getStrengthHtml({ withText: true })
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const textEl = fixtureEl.querySelector('.strength-text')
+      new Strength(strengthEl) // eslint-disable-line no-new
+
+      passwordInput.value = 'password'
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      expect(textEl.textContent).toEqual('Weak')
+    })
+
+    it('should use custom messages', () => {
+      fixtureEl.innerHTML = getStrengthHtml({ withText: true })
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const textEl = fixtureEl.querySelector('.strength-text')
+      new Strength(strengthEl, { // eslint-disable-line no-new
+        messages: {
+          weak: 'Too weak!',
+          fair: 'Getting better',
+          good: 'Nice!',
+          strong: 'Excellent!'
+        }
+      })
+
+      passwordInput.value = 'password'
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      expect(textEl.textContent).toEqual('Too weak!')
+    })
+  })
+
+  describe('events', () => {
+    it('should trigger strengthChange event', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = getStrengthHtml()
+
+        const strengthEl = fixtureEl.querySelector('.strength')
+        const passwordInput = fixtureEl.querySelector('input[type="password"]')
+        new Strength(strengthEl) // eslint-disable-line no-new
+
+        strengthEl.addEventListener('strengthChange.bs.strength', event => {
+          expect(event.strength).toEqual('weak')
+          expect(event.score).toBeGreaterThan(0)
+          resolve()
+        })
+
+        passwordInput.value = 'password'
+        passwordInput.dispatchEvent(createEvent('input'))
+      })
+    })
+
+    it('should not expose actual password in event', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = getStrengthHtml()
+
+        const strengthEl = fixtureEl.querySelector('.strength')
+        const passwordInput = fixtureEl.querySelector('input[type="password"]')
+        new Strength(strengthEl) // eslint-disable-line no-new
+
+        strengthEl.addEventListener('strengthChange.bs.strength', event => {
+          expect(event.password).toEqual('***')
+          resolve()
+        })
+
+        passwordInput.value = 'password'
+        passwordInput.dispatchEvent(createEvent('input'))
+      })
+    })
+  })
+
+  describe('strength-bar variant', () => {
+    it('should work with strength-bar element', () => {
+      fixtureEl.innerHTML = getStrengthBarHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength-bar')
+      const passwordInput = fixtureEl.querySelector('input[type="password"]')
+      const strength = new Strength(strengthEl)
+
+      passwordInput.value = 'password'
+      passwordInput.dispatchEvent(createEvent('input'))
+
+      expect(strength.getStrength()).toEqual('weak')
+      expect(strengthEl.dataset.bsStrength).toEqual('weak')
+    })
+  })
+
+  describe('dispose', () => {
+    it('should dispose the instance', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const strength = new Strength(strengthEl)
+
+      expect(Strength.getInstance(strengthEl)).not.toBeNull()
+
+      strength.dispose()
+
+      expect(Strength.getInstance(strengthEl)).toBeNull()
+    })
+  })
+
+  describe('getInstance', () => {
+    it('should return strength instance', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const strength = new Strength(strengthEl)
+
+      expect(Strength.getInstance(strengthEl)).toEqual(strength)
+      expect(Strength.getInstance(strengthEl)).toBeInstanceOf(Strength)
+    })
+
+    it('should return null when there is no instance', () => {
+      fixtureEl.innerHTML = '<div></div>'
+
+      const div = fixtureEl.querySelector('div')
+
+      expect(Strength.getInstance(div)).toBeNull()
+    })
+  })
+
+  describe('getOrCreateInstance', () => {
+    it('should return existing instance', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+      const strength = new Strength(strengthEl)
+
+      expect(Strength.getOrCreateInstance(strengthEl)).toEqual(strength)
+      expect(Strength.getOrCreateInstance(strengthEl)).toBeInstanceOf(Strength)
+    })
+
+    it('should create new instance when none exists', () => {
+      fixtureEl.innerHTML = getStrengthHtml()
+
+      const strengthEl = fixtureEl.querySelector('.strength')
+
+      expect(Strength.getInstance(strengthEl)).toBeNull()
+      expect(Strength.getOrCreateInstance(strengthEl)).toBeInstanceOf(Strength)
+    })
+  })
+})
diff --git a/scss/forms/_strength.scss b/scss/forms/_strength.scss
new file mode 100644 (file)
index 0000000..a3c262f
--- /dev/null
@@ -0,0 +1,126 @@
+@use "../config" as *;
+@use "../variables" as *;
+@use "../mixins/border-radius" as *;
+@use "../mixins/transition" as *;
+@use "form-variables" as *;
+
+// scss-docs-start strength-variables
+$strength-height:          .375rem !default;
+$strength-gap:             .25rem !default;
+$strength-margin-top:      .25rem !default;
+$strength-border-radius:   var(--border-radius-pill) !default;
+$strength-bg:              var(--bg-2) !default;
+$strength-transition:      background-color .2s ease-in-out, width .3s ease-in-out !default;
+
+$strength-weak-color:      var(--danger-bg) !default;
+$strength-fair-color:      var(--warning-bg) !default;
+$strength-good-color:      var(--info-bg) !default;
+$strength-strong-color:    var(--success-bg) !default;
+// scss-docs-end strength-variables
+
+@layer forms {
+  // Strength meter container
+  .strength {
+    --strength-height: #{$strength-height};
+    --strength-gap: #{$strength-gap};
+    --strength-bg: #{$strength-bg};
+    --strength-border-radius: #{$strength-border-radius};
+    --strength-color: #{$strength-bg};
+
+    display: flex;
+    gap: var(--strength-gap);
+    width: 100%;
+    margin-top: $strength-margin-top;
+  }
+
+  // Individual strength segments
+  .strength-segment {
+    flex: 1;
+    height: var(--strength-height);
+    background-color: var(--strength-bg);
+    @include border-radius(var(--strength-border-radius));
+    @include transition($strength-transition);
+
+    // Filled state
+    &.active {
+      background-color: var(--strength-color);
+    }
+  }
+
+  // Strength levels - set the color variable
+  .strength[data-bs-strength="weak"] {
+    --strength-color: #{$strength-weak-color};
+  }
+
+  .strength[data-bs-strength="fair"] {
+    --strength-color: #{$strength-fair-color};
+  }
+
+  .strength[data-bs-strength="good"] {
+    --strength-color: #{$strength-good-color};
+  }
+
+  .strength[data-bs-strength="strong"] {
+    --strength-color: #{$strength-strong-color};
+  }
+
+  // Optional text feedback
+  .strength-text {
+    display: block;
+    margin-top: $strength-margin-top;
+    font-size: $small-font-size;
+    color: var(--strength-color, var(--secondary-color));
+    @include transition(color .2s ease-in-out);
+
+    // Hide when empty
+    &:empty {
+      display: none;
+    }
+  }
+
+  // Alternative: Single bar variant (like a progress bar)
+  .strength-bar {
+    --strength-height: #{$strength-height};
+    --strength-bg: #{$strength-bg};
+    --strength-border-radius: #{$strength-border-radius};
+    --strength-color: transparent;
+    --strength-width: 0%;
+
+    width: 100%;
+    height: var(--strength-height);
+    margin-top: $strength-margin-top;
+    overflow: hidden;
+    background-color: var(--strength-bg);
+    @include border-radius(var(--strength-border-radius));
+
+    &::after {
+      display: block;
+      width: var(--strength-width);
+      height: 100%;
+      content: "";
+      background-color: var(--strength-color);
+      @include border-radius(var(--strength-border-radius));
+      @include transition($strength-transition);
+    }
+
+    &[data-bs-strength="weak"] {
+      --strength-color: #{$strength-weak-color};
+      --strength-width: 25%;
+    }
+
+    &[data-bs-strength="fair"] {
+      --strength-color: #{$strength-fair-color};
+      --strength-width: 50%;
+    }
+
+    &[data-bs-strength="good"] {
+      --strength-color: #{$strength-good-color};
+      --strength-width: 75%;
+    }
+
+    &[data-bs-strength="strong"] {
+      --strength-color: #{$strength-strong-color};
+      --strength-width: 100%;
+    }
+  }
+}
index 0798f2003bf301ec8b8abf7719f86017145fe347..5eddb3027ac83213f82ec8886ede5baaf7044c45 100644 (file)
@@ -7,4 +7,5 @@
 @forward "form-range";
 @forward "floating-labels";
 @forward "input-group";
+@forward "strength";
 @forward "validation";
index d448507eb76332bec0f7f168eba9b5c63d62d98e..8da00f9799518b6ade546226ac1e794fd37b8494 100644 (file)
@@ -73,6 +73,7 @@
     - title: Range
     - title: Input group
     - title: Floating labels
+    - title: Password strength
     - title: Layout
     - title: Validation
 
diff --git a/site/src/content/docs/forms/password-strength.mdx b/site/src/content/docs/forms/password-strength.mdx
new file mode 100644 (file)
index 0000000..7f56243
--- /dev/null
@@ -0,0 +1,291 @@
+---
+title: Password strength
+description: Provide visual feedback on password strength with segmented meters or progress bars that update as users type.
+toc: true
+---
+
+## Overview
+
+Strength meters help users create secure passwords by providing real-time feedback on password complexity. Bootstrap's strength component offers:
+
+- **Segmented meter**: Four segments that fill based on strength level
+- **Progress bar variant**: A single bar that grows with strength
+- **Text feedback**: Optional text messages for each strength level
+- **Customizable scoring**: Configure minimum length and scoring criteria
+- **Minimal JavaScript**: Lightweight component following Bootstrap patterns
+
+## Examples
+
+### Segmented
+
+The default strength meter uses four segments that fill progressively as password strength increases. Add `data-bs-strength` to enable the JavaScript behavior.
+
+<Example code={`<div>
+    <label for="password1" class="form-label">Password</label>
+    <input type="password" class="form-control" id="password1" placeholder="Enter password">
+    <div class="strength" data-bs-strength>
+      <div class="strength-segment"></div>
+      <div class="strength-segment"></div>
+      <div class="strength-segment"></div>
+      <div class="strength-segment"></div>
+    </div>
+  </div>`} />
+
+### With text feedback
+
+Add a `.strength-text` element to display strength messages.
+
+<Example code={`<div>
+    <label for="password2" class="form-label">Password</label>
+    <input type="password" class="form-control" id="password2" placeholder="Enter password">
+    <div class="strength" data-bs-strength>
+      <div class="strength-segment"></div>
+      <div class="strength-segment"></div>
+      <div class="strength-segment"></div>
+      <div class="strength-segment"></div>
+    </div>
+    <span class="strength-text"></span>
+  </div>`} />
+
+### Progress bar
+
+Use `.strength-bar` for a single progress bar that grows with password strength.
+
+<Example code={`<div>
+    <label for="password3" class="form-label">Password</label>
+    <input type="password" class="form-control" id="password3" placeholder="Enter password">
+    <div class="strength-bar" data-bs-strength></div>
+  </div>`} />
+
+### Static segmented
+
+You can use the component without JavaScript for static displays by setting the `data-bs-strength` attribute manually.
+
+<Example class="vstack gap-3 fg-3" code={`<div>
+    <div>Weak</div>
+    <div class="strength" data-bs-strength="weak">
+      <div class="strength-segment active"></div>
+      <div class="strength-segment"></div>
+      <div class="strength-segment"></div>
+      <div class="strength-segment"></div>
+    </div>
+  </div>
+  <div>
+    <div>Fair</div>
+    <div class="strength" data-bs-strength="fair">
+      <div class="strength-segment active"></div>
+      <div class="strength-segment active"></div>
+      <div class="strength-segment"></div>
+      <div class="strength-segment"></div>
+    </div>
+  </div>
+  <div>
+    <div>Good</div>
+    <div class="strength" data-bs-strength="good">
+      <div class="strength-segment active"></div>
+      <div class="strength-segment active"></div>
+      <div class="strength-segment active"></div>
+      <div class="strength-segment"></div>
+    </div>
+  </div>
+  <div>
+    <div>Strong</div>
+    <div class="strength" data-bs-strength="strong">
+      <div class="strength-segment active"></div>
+      <div class="strength-segment active"></div>
+      <div class="strength-segment active"></div>
+      <div class="strength-segment active"></div>
+    </div>
+  </div>`} />
+
+### Static progress bar
+
+<Example class="vstack gap-3 fg-3" code={`<div>
+    <div>Weak</div>
+    <div class="strength-bar" data-bs-strength="weak"></div>
+  </div>
+  <div>
+    <div>Fair</div>
+    <div class="strength-bar" data-bs-strength="fair"></div>
+  </div>
+  <div>
+    <div>Good</div>
+    <div class="strength-bar" data-bs-strength="good"></div>
+  </div>
+  <div>
+    <div>Strong</div>
+    <div class="strength-bar" data-bs-strength="strong"></div>
+  </div>`} />
+
+## Strength criteria
+
+The default scoring algorithm evaluates passwords based on:
+
+<BsTable>
+| Criteria | Points |
+| --- | --- |
+| Meets minimum length (8 characters) | +1 |
+| 4+ characters over minimum | +1 |
+| Contains lowercase letters | +1 |
+| Contains uppercase letters | +1 |
+| Contains numbers | +1 |
+| Contains special characters | +1 |
+| Multiple special characters | +1 |
+| 16+ characters | +1 |
+</BsTable>
+
+<BsTable>
+| Level | Points |
+| --- | --- |
+| Weak | 1-2 |
+| Fair | 3-4 |
+| Good | 5-6 |
+| Strong | 7-8 |
+</BsTable>
+
+## Usage
+
+### Via data attributes
+
+Add `data-bs-strength` to automatically initialize. The component will look for a password input in the same parent container.
+
+```html
+<div>
+  <input type="password" class="form-control">
+  <div class="strength" data-bs-strength>
+    <div class="strength-segment"></div>
+    <div class="strength-segment"></div>
+    <div class="strength-segment"></div>
+    <div class="strength-segment"></div>
+  </div>
+</div>
+```
+
+### Via JavaScript
+
+```js
+const element = document.querySelector('.strength')
+const strength = new bootstrap.Strength(element, {
+  input: '#my-password-input',
+  minLength: 10,
+  messages: {
+    weak: 'Too weak',
+    fair: 'Could be better',
+    good: 'Good password',
+    strong: 'Excellent!'
+  }
+})
+```
+
+### Options
+
+<BsTable>
+| Name | Type | Default | Description |
+| --- | --- | --- | --- |
+| `input` | string \| element | `null` | Selector or element for the password input. If not provided, looks for `input[type="password"]` in the parent. |
+| `minLength` | number | `8` | Minimum password length for first strength point. |
+| `messages` | object | `{weak, fair, good, strong}` | Custom messages for each strength level. |
+| `weights` | object | See below | Point values for each scoring criterion. Set to `0` to disable a criterion. |
+| `thresholds` | array | `[2, 4, 6]` | Score boundaries for strength levels: `[weak, fair, good]`. Scores above the last value are "strong". |
+| `scorer` | function | `null` | Custom scoring function `(password) => number`. Overrides built-in scoring when provided. |
+</BsTable>
+
+#### Default weights
+
+```js
+{
+  minLength: 1,        // Meets minimum length
+  extraLength: 1,      // 4+ characters over minimum
+  lowercase: 1,        // Contains lowercase letters
+  uppercase: 1,        // Contains uppercase letters
+  numbers: 1,          // Contains numbers
+  special: 1,          // Contains special characters
+  multipleSpecial: 1,  // Multiple special characters
+  longPassword: 1      // 16+ characters
+}
+```
+
+#### Custom weights
+
+```js
+// Disable uppercase requirement, make special chars worth more
+new bootstrap.Strength(element, {
+  weights: {
+    uppercase: 0,   // Don't require uppercase
+    special: 2      // Special chars worth 2 points
+  },
+  thresholds: [3, 5, 7]  // Adjust thresholds for new max score
+})
+```
+
+#### Custom scorer
+
+```js
+// Fully custom scoring logic
+new bootstrap.Strength(element, {
+  scorer: (password) => {
+    let score = 0
+    if (password.length >= 12) score += 4
+    if (/[!@#$%]/.test(password)) score += 4
+    return score
+  },
+  thresholds: [2, 4, 6]
+})
+```
+
+### Methods
+
+| Method | Description |
+| --- | --- |
+| `getStrength()` | Returns the current strength level (`'weak'`, `'fair'`, `'good'`, `'strong'`, or `null`). |
+| `evaluate()` | Manually trigger a password evaluation. |
+| `dispose()` | Destroys the component instance. |
+
+```js
+const element = document.querySelector('.strength')
+const strength = bootstrap.Strength.getOrCreateInstance(element)
+
+// Get current strength
+console.log(strength.getStrength()) // 'fair'
+```
+
+### Events
+
+| Event | Description |
+| --- | --- |
+| `strengthChange.bs.strength` | Fired when the strength level changes. Includes `strength` and `score` properties. |
+
+```js
+const element = document.querySelector('.strength')
+
+element.addEventListener('strengthChange.bs.strength', event => {
+  console.log('Strength:', event.strength) // 'weak', 'fair', 'good', 'strong'
+  console.log('Score:', event.score) // 0-8
+})
+```
+
+## CSS-only usage
+
+For simple cases or server-rendered content, you can use the component without JavaScript by manually setting the `data-bs-strength` attribute and `.active` classes.
+
+```html
+<!-- Set via server-side logic -->
+<div class="strength" data-bs-strength="good">
+  <div class="strength-segment active"></div>
+  <div class="strength-segment active"></div>
+  <div class="strength-segment active"></div>
+  <div class="strength-segment"></div>
+</div>
+```
+
+## Accessibility
+
+- The strength meter is decorative feedback and should be used alongside clear password requirements text
+- Consider using `aria-describedby` to associate the password input with requirement text
+- Screen reader users benefit from the text feedback option which announces strength changes
+
+## CSS
+
+### Sass variables
+
+<ScssDocs name="strength-variables" file="scss/forms/_strength.scss" />