Add a password strength meter (#3054)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-14 20:22:23 +02:00
1 parent 5d5be36332
commit 9669c0a9a9
16 files changed
+990 -34

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@tabler/core": minor
"@tabler/preview": minor
"@tabler/docs": minor
---
Added a `Strength` password meter with a segmented bar, configurable scoring and `change.bs.strength`.
+278
View File
@@ -0,0 +1,278 @@
/**
* --------------------------------------------------------------------------
* Tabler strength.ts
* Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine'
import type { ElementSelector } from './bootstrap/types'
type StrengthLevel = 'weak' | 'fair' | 'good' | 'strong'
type ComponentConfig = {
/** selector of the password field; without it the meter looks in its own parent */
input: string | null
minLength: number
messages: Record<StrengthLevel, string>
weights: Record<string, number>
/** score bounds: weak up to the first, fair to the second, good to the third */
thresholds: number[]
/** replaces the built-in scoring; gets the password, returns a number */
scorer: ((password: string) => number) | null
}
type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
/**
* Constants
*/
const NAME = 'strength'
const DATA_KEY = `bs.${NAME}`
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_CHANGE = `change${EVENT_KEY}`
const CLASS_NAME_ACTIVE = 'active'
const SELECTOR_DATA_STRENGTH = `[data-bs-${NAME}], [data-tblr-${NAME}]`
const SELECTOR_SEGMENT = `.${NAME}-segment`
const SELECTOR_TEXT = `.${NAME}-text`
const SELECTOR_PASSWORD = 'input[type="password"]'
const LEVELS: StrengthLevel[] = ['weak', 'fair', 'good', 'strong']
const Default: ComponentConfig = {
input: null,
minLength: 8,
messages: {
weak: 'Weak',
fair: 'Fair',
good: 'Good',
strong: 'Strong',
},
weights: {
minLength: 1,
extraLength: 1,
longPassword: 1,
lowercase: 1,
uppercase: 1,
numbers: 1,
special: 1,
multipleSpecial: 1,
},
thresholds: [2, 4, 6],
scorer: null,
}
const DefaultType: Record<keyof ComponentConfig, string> = {
input: '(string|null)',
minLength: 'number',
messages: 'object',
weights: 'object',
thresholds: 'array',
scorer: '(function|null)',
}
/**
* Class definition
*
* Rates the password typed in a field and fills a segmented meter. The score
* is a hint for the user, never a validation: check the password on the
* server as well.
*/
class Strength extends BaseComponent {
declare _element: HTMLElement
declare _config: ComponentConfig
_input: HTMLInputElement | null = null
_segments: HTMLElement[] = []
_text: HTMLElement | null = null
// What the markup put in the label element: shown while the field is empty,
// so a form can keep its password rules there until a level replaces them.
_emptyText = ''
_level: StrengthLevel | null = null
_onInput = (): void => this.evaluate()
constructor(element: ElementSelector, config?: ComponentConfigInput) {
super(element, config)
if (!this._element) {
return
}
this._input = this._getInput()
this._segments = SelectorEngine.find(SELECTOR_SEGMENT, this._element)
this._text = this._getText()
this._emptyText = this._text?.textContent?.trim() ?? ''
this._setUpAria()
if (!this._input) {
return
}
this._input.addEventListener('input', this._onInput)
this.evaluate()
}
// Getters
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): Record<keyof ComponentConfig, string> {
return DefaultType
}
static get NAME(): string {
return NAME
}
get level(): StrengthLevel | null {
return this._level
}
// Public
evaluate(): void {
if (!this._input) {
return
}
const password = this._input.value
const score = this._score(password)
const level = this._level_(score)
if (level === this._level) {
return
}
this._level = level
this._render(level)
// The password itself is never part of the payload.
EventHandler.trigger(this._element, EVENT_CHANGE, { strength: level, score })
}
dispose(): void {
this._input?.removeEventListener('input', this._onInput)
super.dispose()
}
// Private
_getInput(): HTMLInputElement | null {
const { input } = this._config
if (input) {
return SelectorEngine.findOne(input) as HTMLInputElement | null
}
// Without an explicit selector only the meter's own parent is searched, so
// a form with several password fields cannot bind the wrong one.
const parent = this._element.parentElement
const fields = parent ? (SelectorEngine.find(SELECTOR_PASSWORD, parent) as HTMLInputElement[]) : []
return fields[fields.length - 1] ?? null
}
_getText(): HTMLElement | null {
const parent = this._element.parentElement
return parent ? SelectorEngine.findOne(SELECTOR_TEXT, parent) : null
}
_setUpAria(): void {
const element = this._element
element.setAttribute('role', 'progressbar')
element.setAttribute('aria-valuemin', '0')
element.setAttribute('aria-valuemax', String(LEVELS.length))
element.setAttribute('aria-valuenow', '0')
if (!element.hasAttribute('aria-label') && !element.hasAttribute('aria-labelledby')) {
element.setAttribute('aria-label', 'Password strength')
}
// The segments are decoration; the level is read from the meter itself.
for (const segment of this._segments) {
segment.setAttribute('aria-hidden', 'true')
}
this._text?.setAttribute('aria-live', 'polite')
}
_score(password: string): number {
if (!password) {
return 0
}
const { scorer, weights, minLength } = this._config
if (typeof scorer === 'function') {
return scorer(password)
}
const rules: [boolean, number | undefined][] = [
[password.length >= minLength, weights.minLength],
[password.length >= minLength + 4, weights.extraLength],
[password.length >= 16, weights.longPassword],
[/[a-z]/.test(password), weights.lowercase],
[/[A-Z]/.test(password), weights.uppercase],
[/\d/.test(password), weights.numbers],
[/[^\dA-Za-z]/.test(password), weights.special],
[(password.match(/[^\dA-Za-z]/g) ?? []).length > 1, weights.multipleSpecial],
]
return rules.reduce((score, [passed, weight]) => score + (passed ? (weight ?? 0) : 0), 0)
}
// Trailing underscore: `_level` is the field holding the current value.
_level_(score: number): StrengthLevel | null {
if (score <= 0) {
return null
}
const index = this._config.thresholds.findIndex((threshold) => score <= threshold)
return LEVELS[index === -1 ? LEVELS.length - 1 : index] ?? null
}
_render(level: StrengthLevel | null): void {
const element = this._element
const index = level ? LEVELS.indexOf(level) : -1
// Emptied rather than removed: `data-bs-strength` is also the attribute
// the data API looks for, and an empty value matches no level in CSS.
element.dataset.bsStrength = level ?? ''
element.setAttribute('aria-valuenow', String(index + 1))
// No level means no spoken value: `aria-valuenow` of 0 says it already,
// and an invented word here could not be translated through `messages`.
if (level) {
element.setAttribute('aria-valuetext', this._config.messages[level] ?? level)
} else {
element.removeAttribute('aria-valuetext')
}
// The segments are filled in proportion, so a meter with three or five of
// them works as well as the usual four.
const filled = level ? Math.ceil(((index + 1) / LEVELS.length) * this._segments.length) : 0
for (const [position, segment] of this._segments.entries()) {
segment.classList.toggle(CLASS_NAME_ACTIVE, position < filled)
}
if (this._text) {
this._text.textContent = level ? (this._config.messages[level] ?? '') : this._emptyText
this._text.dataset.bsStrength = level ?? ''
}
}
}
/**
* Data API implementation
*/
// js-docs-start strength-init
for (const element of SelectorEngine.find(SELECTOR_DATA_STRENGTH)) {
Strength.getOrCreateInstance(element)
}
// js-docs-end strength-init
export default Strength
+2
View File
@@ -10,6 +10,7 @@ import './src/tab'
import './src/toast' import './src/toast'
import './src/sortable' import './src/sortable'
import './src/sparkline' import './src/sparkline'
import './src/strength'
// Re-export everything from bootstrap.ts (single source of truth) // Re-export everything from bootstrap.ts (single source of truth)
export * from './src/bootstrap' export * from './src/bootstrap'
@@ -20,4 +21,5 @@ export { default as CountUp } from './src/countup'
export { default as InputMask } from './src/input-mask' export { default as InputMask } from './src/input-mask'
export { default as Sortable } from './src/sortable' export { default as Sortable } from './src/sortable'
export { default as Sparkline } from './src/sparkline' export { default as Sparkline } from './src/sparkline'
export { default as Strength } from './src/strength'
export { default as SwitchIcon } from './src/switch-icon' export { default as SwitchIcon } from './src/switch-icon'
+200
View File
@@ -0,0 +1,200 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import { clearFixture, getFixture } from '../helpers/fixture'
import Strength from '../../src/strength'
const METER = `
<div>
<input type="password" id="password">
<div class="strength" data-bs-strength>
<span class="strength-segment"></span>
<span class="strength-segment"></span>
<span class="strength-segment"></span>
<span class="strength-segment"></span>
</div>
<small class="strength-text"></small>
</div>`
describe('Strength', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
fixtureEl.innerHTML = METER
})
afterEach(() => {
clearFixture()
})
const meter = (): HTMLElement => fixtureEl.querySelector('.strength')!
const input = (): HTMLInputElement => fixtureEl.querySelector('input')!
const text = (): HTMLElement => fixtureEl.querySelector('.strength-text')!
const filled = (): number => fixtureEl.querySelectorAll('.strength-segment.active').length
const type = (value: string): void => {
input().value = value
input().dispatchEvent(new Event('input'))
}
describe('NAME', () => {
it('should return plugin name', () => {
expect(Strength.NAME).toBe('strength')
})
})
describe('constructor', () => {
it('should bind the password field in its own parent and start empty', () => {
const instance = new Strength(meter())
expect(instance._input).toBe(input())
expect(instance.level).toBeNull()
expect(filled()).toBe(0)
expect(text().textContent).toBe('')
})
it('should read an existing value right away', () => {
input().value = 'Sup3rSecret!Pass'
new Strength(meter())
expect(meter().dataset.bsStrength).toBe('strong')
})
it('should take the field from the input option', () => {
fixtureEl.innerHTML = `<input type="password" id="remote"><div class="strength" data-bs-strength data-bs-input="#remote"><span class="strength-segment"></span></div>`
const instance = new Strength(meter())
expect(instance._input).toBe(fixtureEl.querySelector('#remote'))
})
})
describe('levels', () => {
it('should climb from weak to strong as the password improves', () => {
new Strength(meter())
type('abc')
expect(meter().dataset.bsStrength).toBe('weak')
expect(text().textContent).toBe('Weak')
expect(filled()).toBe(1)
type('abcdefghijkl')
expect(meter().dataset.bsStrength).toBe('fair')
expect(filled()).toBe(2)
type('Abcdefgh1!')
expect(meter().dataset.bsStrength).toBe('good')
expect(filled()).toBe(3)
type('Abcdefgh1!?x')
expect(meter().dataset.bsStrength).toBe('strong')
expect(filled()).toBe(4)
})
it('should put the markup text back when the field is emptied', () => {
fixtureEl.innerHTML = METER.replace('<small class="strength-text"></small>', '<small class="strength-text">At least 8 characters</small>')
new Strength(meter())
expect(text().textContent).toBe('At least 8 characters')
type('abc')
expect(text().textContent).toBe('Weak')
type('')
expect(text().textContent).toBe('At least 8 characters')
})
it('should keep the trigger attribute when the field is emptied', () => {
new Strength(meter())
type('Abcdefgh1!?x')
type('')
expect(meter().dataset.bsStrength).toBe('')
expect(text().textContent).toBe('')
expect(filled()).toBe(0)
})
it('should fire change.bs.strength once per level, without the password', () => {
const spy = vi.fn()
meter().addEventListener('change.bs.strength', spy)
new Strength(meter())
type('abc')
type('abcd')
expect(spy).toHaveBeenCalledTimes(1)
const event = spy.mock.calls[0][0] as CustomEvent & { strength: string; score: number; password?: string }
expect(event.strength).toBe('weak')
expect(event.score).toBe(1)
expect(event.password).toBeUndefined()
})
})
describe('config', () => {
it('should use a custom scorer and messages', () => {
new Strength(meter(), { scorer: () => 9, messages: { weak: 'Słabe', fair: 'Średnie', good: 'Dobre', strong: 'Mocne' } })
type('x')
expect(meter().dataset.bsStrength).toBe('strong')
expect(text().textContent).toBe('Mocne')
})
it('should respect custom thresholds', () => {
new Strength(meter(), { thresholds: [10, 20, 30] })
type('Abcdefgh1!?x')
expect(meter().dataset.bsStrength).toBe('weak')
})
it('should fill a meter with a different number of segments', () => {
fixtureEl.innerHTML = `<div><input type="password"><div class="strength" data-bs-strength><span class="strength-segment"></span><span class="strength-segment"></span></div></div>`
new Strength(meter())
type('abc')
expect(filled()).toBe(1)
})
})
describe('accessibility', () => {
it('should describe the meter and announce the label politely', () => {
new Strength(meter())
expect(meter().getAttribute('role')).toBe('progressbar')
expect(meter().getAttribute('aria-valuemax')).toBe('4')
expect(meter().getAttribute('aria-label')).toBe('Password strength')
expect(text().getAttribute('aria-live')).toBe('polite')
expect(fixtureEl.querySelector('.strength-segment')!.getAttribute('aria-hidden')).toBe('true')
type('Abcdefgh1!?x')
expect(meter().getAttribute('aria-valuenow')).toBe('4')
expect(meter().getAttribute('aria-valuetext')).toBe('Strong')
type('')
expect(meter().getAttribute('aria-valuenow')).toBe('0')
expect(meter().hasAttribute('aria-valuetext')).toBe(false)
})
it('should keep an aria-label from the markup', () => {
meter().setAttribute('aria-label', 'Siła hasła')
new Strength(meter())
expect(meter().getAttribute('aria-label')).toBe('Siła hasła')
})
})
describe('dispose', () => {
it('should stop listening and remove the instance', () => {
const instance = new Strength(meter())
instance.dispose()
type('Abcdefgh1!?x')
expect(meter().dataset.bsStrength).toBe('')
expect(Strength.getInstance(meter())).toBeNull()
})
})
})
+1
View File
@@ -67,6 +67,7 @@
@forward 'ui/forms/form-custom'; @forward 'ui/forms/form-custom';
@forward 'ui/forms/form-check'; @forward 'ui/forms/form-check';
@forward 'ui/forms/validation'; @forward 'ui/forms/validation';
@forward 'ui/forms/strength';
@forward 'ui/legend'; @forward 'ui/legend';
@forward 'ui/lists'; @forward 'ui/lists';
@forward 'ui/loaders'; @forward 'ui/loaders';
+17
View File
@@ -1873,6 +1873,23 @@ $progressbg-padding-x: 0.5rem !default;
$progressbg-value-padding-start: 2rem !default; $progressbg-value-padding-start: 2rem !default;
$progress-separated-ring-width: 2px !default; $progress-separated-ring-width: 2px !default;
$progress-steps-gap: 0.25rem !default; $progress-steps-gap: 0.25rem !default;
// Password strength
// scss-docs-start strength-variables
$strength-height: 0.25rem !default;
$strength-gap: 0.25rem !default;
$strength-bg: var(--border-color) !default;
$strength-border-radius: var(--border-radius-pill) !default;
$strength-text-font-size: $small-font-size !default;
$strength-margin-top: 0.5rem !default;
$strength-text-margin-top: 0.25rem !default;
$strength-levels: (
'weak': var(--danger),
'fair': var(--warning),
'good': var(--info),
'strong': var(--success),
) !default;
// scss-docs-end strength-variables
$progress-steps-item-height: 0.25rem !default; $progress-steps-item-height: 0.25rem !default;
// scss-docs-end progress-variables // scss-docs-end progress-variables
+63
View File
@@ -0,0 +1,63 @@
/*
Password strength meter
*/
@use '../../config' as *;
.strength {
--strength-height: #{$strength-height};
--strength-gap: #{$strength-gap};
--strength-bg: #{$strength-bg};
--strength-border-radius: #{$strength-border-radius};
--strength-color: #{$strength-bg};
--strength-margin-top: #{$strength-margin-top};
display: flex;
gap: var(--strength-gap);
width: 100%;
// Spaced like a form hint, so the meter sits under the control it rates
// without a utility class at every call site.
.form-control + &,
.form-select + &,
.input-group + &,
.form-hint + & {
margin-top: var(--strength-margin-top);
}
}
.strength-segment {
flex: 1;
height: var(--strength-height);
background: var(--strength-bg);
border-radius: var(--strength-border-radius);
transition: background-color 0.2s ease-in-out;
&.active {
background: var(--strength-color);
}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
.strength + .strength-text {
margin-top: #{$strength-text-margin-top};
}
.strength-text {
font-size: $strength-text-font-size;
color: var(--strength-color, var(--secondary));
// An empty label must not reserve a line before the user types.
&:empty {
display: none;
}
}
@each $level, $color in $strength-levels {
.strength[data-bs-strength='#{$level}'],
.strength-text[data-bs-strength='#{$level}'] {
--strength-color: #{$color};
}
}
+165
View File
@@ -0,0 +1,165 @@
---
title: Password strength
seoTitle: Password strength meter
order: 13
summary: A strength meter rates the password as the user types and fills a segmented bar, so weak passwords are visible before the form is sent.
description: Show how strong a password is with a segmented meter that rates it as the user types, with configurable scoring and labels.
added-in: '1.6.0'
related: [/ui/forms/elements, /ui/forms/validation]
---
import Example from '@components/Example.astro';
import CodeDocs from '@components/CodeDocs.astro';
## Overview
A password field says nothing about the password it holds. The strength meter rates what the user typed and fills a segmented bar, from weak to strong, with a short label under it.
The meter is a hint for the user, not a validation. Check the password on the server as well, and never let the meter decide whether a form can be sent.
<Example column>
<div>
<label class="form-label" for="strength-overview">Password</label>
<input type="password" id="strength-overview" class="form-control" placeholder="Type a password…" autocomplete="new-password" />
<div class="strength" data-bs-strength aria-label="Password strength">
<span class="strength-segment"></span>
<span class="strength-segment"></span>
<span class="strength-segment"></span>
<span class="strength-segment"></span>
</div>
<div class="strength-text"></div>
</div>
</Example>
## Usage
Write the meter as a `strength` element with one `strength-segment` per bar, and add `data-bs-strength`. Tabler finds it on page load and rates the password field that sits in the same parent element. Add a `strength-text` next to the meter for the label; it stays hidden until the user types.
A meter that follows a control gets its own top margin, so it needs no margin utility.
Text written inside `strength-text` shows while the field is empty and comes back when it is cleared, so the password rules can stand there until a level takes over.
<Example column>
<div>
<label class="form-label" for="strength-rules">Password</label>
<input type="password" id="strength-rules" class="form-control" placeholder="Type a password…" autocomplete="new-password" />
<div class="strength" data-bs-strength aria-label="Password strength">
<span class="strength-segment"></span>
<span class="strength-segment"></span>
<span class="strength-segment"></span>
<span class="strength-segment"></span>
</div>
<div class="strength-text">Use 8 to 20 characters, with letters and numbers.</div>
</div>
</Example>
```html
<input type="password" class="form-control" autocomplete="new-password" />
<div class="strength" data-bs-strength aria-label="Password strength">
<span class="strength-segment"></span>
<span class="strength-segment"></span>
<span class="strength-segment"></span>
<span class="strength-segment"></span>
</div>
<div class="strength-text"></div>
```
### Pointing at a field
A form with several password fields, or a meter that does not sit next to its input, needs `data-bs-input` with the selector of the field.
```html
<div class="strength" data-bs-strength data-bs-input="#password-new" aria-label="New password strength">…</div>
```
### Number of segments
The segments are filled in proportion to the level, so a meter can have any number of them. Four segments give one per level; with three, two levels share the last segment.
<Example column>
<div>
<label class="form-label" for="strength-segments">Password</label>
<input type="password" id="strength-segments" class="form-control" value="abcdefghijkl" autocomplete="new-password" />
<div class="strength" data-bs-strength aria-label="Password strength">
<span class="strength-segment"></span>
<span class="strength-segment"></span>
<span class="strength-segment"></span>
</div>
</div>
</Example>
## Options
Pass options as data attributes on the meter, or in the config object.
| Option | Default | Description |
| --- | --- | --- |
| `input` | none | Selector of the password field. Without it the meter uses the password field in its own parent. |
| `minLength` | `8` | The length the built-in scoring counts as long enough. |
| `messages` | `Weak`, `Fair`, `Good`, `Strong` | The label for each level. |
| `weights` | `1` each | Points for each rule of the built-in scoring. |
| `thresholds` | `[2, 4, 6]` | Score bounds: weak up to the first, fair to the second, good to the third, strong above it. |
| `scorer` | none | A function that takes the password and returns a number, replacing the built-in scoring. Config object only. |
The built-in scoring gives a point each for: reaching `minLength`, four characters more, sixteen characters, a lowercase letter, an uppercase letter, a digit, a special character, and a second special character.
```html
<div class="strength" data-bs-strength data-bs-min-length="12" data-bs-thresholds="[3,5,7]" aria-label="Password strength">…</div>
```
## JavaScript
Tabler ships a `Strength` component that works like the Bootstrap components: it is created once per element and stored on it. On page load Tabler creates one for every element with `data-bs-strength`. This is the code that runs:
<CodeDocs name="strength-init" file="core/js/src/strength.ts" />
Create the component yourself for a form added later, or to pass options the attributes cannot carry:
```js
const meter = tabler.Strength.getOrCreateInstance(document.getElementById('meter'), {
input: '#password-new',
scorer: (password) => zxcvbn(password).score * 2,
});
```
Every change of level fires `change.bs.strength` on the meter. The event carries the level and the score, never the password:
```js
document.getElementById('meter').addEventListener('change.bs.strength', (event) => {
console.log(event.strength, event.score);
});
```
| Method | Description |
| --- | --- |
| `evaluate()` | Rates the field again, for example after the value was set from code. |
| `level` | Getter. The current level, or `null` when the field is empty. |
| `dispose()` | Stops listening and removes the component from the element. |
| `getInstance(element)` | Static. Returns the component for the element, or `null`. |
| `getOrCreateInstance(element, config)` | Static. Returns the component for the element and creates it when needed. |
## Accessibility
- **Name the meter.** Write an `aria-label` (or `aria-labelledby`) on it, as every example here does. Tabler falls back to the English `Password strength` only when the markup names nothing, so a page in another language should always carry its own label.
- The meter is a `progressbar`. Tabler keeps `aria-valuenow` on it and puts the current level in `aria-valuetext`. An empty field drops `aria-valuetext` and leaves the value at zero, so nothing untranslated is ever spoken.
- The segments are marked `aria-hidden`, so a screen reader reads the level once instead of counting bars.
- The label under the meter is announced politely, and only when the level changes, so typing does not flood a screen reader.
- Colour alone does not carry the result: the label says the same thing in words, and the number of filled segments repeats it.
- A meter says how strong the password is, not which rules a form enforces. Put the rules in `strength-text`, where they show until the first letter, or in a `form-hint` when they have to stay visible the whole time.
## Translations
Every word the meter shows comes from your markup or your config, so there is nothing to patch in Tabler to run it in another language.
Set the level labels with `data-bs-messages` and name the meter with an `aria-label` in the same language:
```html
<div class="strength" data-bs-strength aria-label="Siła hasła" data-bs-messages='{"weak":"Słabe","fair":"Średnie","good":"Dobre","strong":"Mocne"}'>…</div>
```
The labels are also what a screen reader announces through `aria-valuetext`, so translating `messages` covers the spoken output as well.
## Variables
Use these SCSS variables to customize the meter. The default values are:
<CodeDocs name="strength-variables" file="core/scss/_variables.scss" />
+168
View File
@@ -0,0 +1,168 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import CaptureScript from '@shared/components/CaptureScript.astro'
import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import FormGroup from '@ui/FormGroup.astro'
import InputGroup from '@ui/InputGroup.astro'
import Strength from '@ui/Strength.astro'
import Badge from '@ui/Badge.astro'
import Button from '@ui/Button.astro'
import DocsLink from '@ui/DocsLink.astro'
---
<DefaultLayout title="Password strength" pageMenu="base.password-strength" description="A strength meter rates the password as the user types and fills a segmented bar, so weak passwords are visible before the form is sent.">
<DocsLink slot="page-header-actions" path="/ui/forms/strength" />
<div class="row row-cards">
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Basic</CardTitle>
<CardSubtitle>Add <code>data-bs-strength</code> to a <code>strength</code> element next to the field. A <code>strength-text</code> shows the label.</CardSubtitle>
<FormGroup label="Password" for="strength-basic" class="mb-0">
<input type="password" id="strength-basic" class="form-control" placeholder="Type a password…" autocomplete="new-password" />
<Strength />
</FormGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Rules until the first letter</CardTitle>
<CardSubtitle>Text written inside <code>strength-text</code> shows while the field is empty, so the password rules can stand there until a level takes over.</CardSubtitle>
<FormGroup label="Password" for="strength-hint" class="mb-0">
<input type="password" id="strength-hint" class="form-control" placeholder="Type a password…" autocomplete="new-password" />
<Strength>Use 8 to 20 characters, with letters and numbers.</Strength>
</FormGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Without a label</CardTitle>
<CardSubtitle>The <code>strength-text</code> is optional. Left out, the meter is the only feedback.</CardSubtitle>
<FormGroup label="Password" for="strength-bare" class="mb-0">
<input type="password" id="strength-bare" class="form-control" value="Abcdefgh1!" autocomplete="new-password" />
<Strength showText={false} />
</FormGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Number of segments</CardTitle>
<CardSubtitle>Segments fill in proportion, so a meter can have three, four or more of them.</CardSubtitle>
<FormGroup label="Three segments" for="strength-three">
<input type="password" id="strength-three" class="form-control" value="abcdefghijkl" autocomplete="new-password" />
<Strength segments={3} showText={false} ariaLabel="Password strength, three segments" />
</FormGroup>
<FormGroup label="Six segments" for="strength-six" class="mb-0">
<input type="password" id="strength-six" class="form-control" value="abcdefghijkl" autocomplete="new-password" />
<Strength segments={6} showText={false} ariaLabel="Password strength, six segments" />
</FormGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Own labels</CardTitle>
<CardSubtitle>Pass <code>data-bs-messages</code> as JSON to rename the levels.</CardSubtitle>
<FormGroup label="Password" for="strength-messages" class="mb-0">
<input type="password" id="strength-messages" class="form-control" value="Abcdefgh1!" autocomplete="new-password" />
<Strength messages={{ weak: 'Too easy', fair: 'Could be better', good: 'Solid', strong: 'Excellent' }} />
</FormGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Stricter scoring</CardTitle>
<CardSubtitle>Raise <code>data-bs-min-length</code> and <code>data-bs-thresholds</code> to demand more before a password counts as strong.</CardSubtitle>
<FormGroup label="Password" for="strength-strict" hint="The same password rates lower here than in the first card." class="mb-0">
<input type="password" id="strength-strict" class="form-control" value="Abcdefgh1!" autocomplete="new-password" />
<Strength minLength={12} thresholds={[3, 5, 7]} />
</FormGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Pointing at a field</CardTitle>
<CardSubtitle>With several password fields, name the one to rate with <code>data-bs-input</code>.</CardSubtitle>
<FormGroup label="New password" for="strength-new">
<InputGroup type="password" id="strength-new" placeholder="Enter a new password" autocomplete="new-password" appendButton={[{ icon: 'eye', description: 'Show password' }]} flat />
<Strength input="#strength-new" ariaLabel="New password strength" />
</FormGroup>
<FormGroup label="Confirm password" for="strength-confirm" class="mb-0">
<InputGroup type="password" id="strength-confirm" placeholder="Repeat the password" autocomplete="new-password" appendButton={[{ icon: 'eye', description: 'Show password' }]} flat />
</FormGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Reacting to the level</CardTitle>
<CardSubtitle>Every change of level fires <code>change.bs.strength</code> with the level and the score.</CardSubtitle>
<FormGroup label="Password" for="strength-event">
<input type="password" id="strength-event" class="form-control" placeholder="Type a password…" autocomplete="new-password" />
<Strength id="strength-event-meter" showText={false} />
</FormGroup>
<div class="d-flex align-items-center gap-2">
<Badge id="strength-event-level" color="secondary" light text="No password" />
<Button id="strength-event-submit" type="button" text="Create account" color="primary" size="sm" disabled />
</div>
<CaptureScript>
<!-- BEGIN SCRIPT OF PASSWORD STRENGTH -->
<script is:inline>
/*
Shows the level in a badge and only enables the button from "good" up.
The meter stays a hint: a real form validates on the server too.
*/
document.addEventListener('DOMContentLoaded', () => {
const meter = document.getElementById('strength-event-meter')
const badge = document.getElementById('strength-event-level')
const submit = document.getElementById('strength-event-submit')
const colors = { weak: 'red', fair: 'yellow', good: 'azure', strong: 'green' }
meter.addEventListener('change.bs.strength', (event) => {
const level = event.strength
badge.className = `badge bg-${level ? colors[level] : 'secondary'}-lt`
badge.textContent = level ? `${level} (${event.score} ${event.score === 1 ? 'point' : 'points'})` : 'No password'
submit.disabled = level !== 'good' && level !== 'strong'
})
})
</script>
<!-- END SCRIPT OF PASSWORD STRENGTH -->
</CaptureScript>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Without a field</CardTitle>
<CardSubtitle>Without a password field in its parent and without <code>data-bs-input</code>, the meter stays empty.</CardSubtitle>
<Strength showText={false} />
</CardBody>
</Card>
</div>
</div>
</DefaultLayout>
+28
View File
@@ -0,0 +1,28 @@
---
// Password strength meter in its natural place: the password field of a sign-up
// card. The field carries a value, so tabler.js rates it on load and the meter
// shows a filled state with its label instead of an empty bar.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import FormGroup from '@ui/FormGroup.astro'
import Strength from '@ui/Strength.astro'
import Button from '@ui/Button.astro'
---
<ScreenshotLayout title="Password strength" columns={1.6}>
<Card>
<CardBody class="p-4">
<CardTitle class="mb-3">Create your account</CardTitle>
<FormGroup label="Email address" for="shot-email">
<input type="email" id="shot-email" class="form-control" value="paweł@tabler.io" autocomplete="email" />
</FormGroup>
<FormGroup label="Password" for="shot-password" class="mb-4">
<input type="password" id="shot-password" class="form-control" value="Tabler2026!" autocomplete="new-password" />
<Strength>Use 8 to 20 characters, with letters and numbers.</Strength>
</FormGroup>
<Button text="Create account" color="primary" block />
</CardBody>
</Card>
</ScreenshotLayout>
+2
View File
@@ -2,6 +2,7 @@
import FormFooter from '@ui/FormFooter.astro' import FormFooter from '@ui/FormFooter.astro'
import InputGroup from '@ui/InputGroup.astro' import InputGroup from '@ui/InputGroup.astro'
import FormGroup from '@ui/FormGroup.astro' import FormGroup from '@ui/FormGroup.astro'
import Strength from '@ui/Strength.astro'
import CardTitle from '@ui/CardTitle.astro' import CardTitle from '@ui/CardTitle.astro'
--- ---
@@ -17,6 +18,7 @@ import CardTitle from '@ui/CardTitle.astro'
</FormGroup> </FormGroup>
<FormGroup label="Password" for="signup-password"> <FormGroup label="Password" for="signup-password">
<InputGroup type="password" appendButton={[{ icon: 'eye', description: 'Show password' }]} flat placeholder="Password" id="signup-password" autocomplete="new-password" required /> <InputGroup type="password" appendButton={[{ icon: 'eye', description: 'Show password' }]} flat placeholder="Password" id="signup-password" autocomplete="new-password" required />
<Strength />
</FormGroup> </FormGroup>
<div class="mb-3"> <div class="mb-3">
<label class="form-check"> <label class="form-check">
@@ -1,4 +1,5 @@
--- ---
import Strength from '@ui/Strength.astro'
import FormGroup from '@ui/FormGroup.astro' import FormGroup from '@ui/FormGroup.astro'
--- ---
@@ -360,6 +361,11 @@ import FormGroup from '@ui/FormGroup.astro'
<input type="text" name="input-mask" id="fe5-input-mask-tel" class="form-control" data-mask="(00) 0000-0000" data-mask-visible="true" placeholder="(00) 0000-0000" autocomplete="off" /> <input type="text" name="input-mask" id="fe5-input-mask-tel" class="form-control" data-mask="(00) 0000-0000" data-mask-visible="true" placeholder="(00) 0000-0000" autocomplete="off" />
</FormGroup> </FormGroup>
<FormGroup label="Password strength" for="fe5-password-strength" hint="The meter rates the password as the user types. It is a hint, not a validation.">
<input type="password" id="fe5-password-strength" class="form-control" placeholder="Type a password…" autocomplete="new-password" />
<Strength />
</FormGroup>
<FormGroup label="Autosize textarea" for="fe5-textarea-autosize"> <FormGroup label="Autosize textarea" for="fe5-textarea-autosize">
<textarea class="form-control" id="fe5-textarea-autosize" data-bs-toggle="autosize" placeholder="Type something…"></textarea> <textarea class="form-control" id="fe5-textarea-autosize" data-bs-toggle="autosize" placeholder="Type something…"></textarea>
</FormGroup> </FormGroup>
@@ -1,8 +1,8 @@
--- ---
import FormHint from '@ui/FormHint.astro'
import Button from '@ui/Button.astro' import Button from '@ui/Button.astro'
import InputGroup from '@ui/InputGroup.astro' import InputGroup from '@ui/InputGroup.astro'
import FormGroup from '@ui/FormGroup.astro' import FormGroup from '@ui/FormGroup.astro'
import Strength from '@ui/Strength.astro'
--- ---
<div class="modal-header"> <div class="modal-header">
@@ -18,13 +18,7 @@ import FormGroup from '@ui/FormGroup.astro'
<FormGroup label="New password" for="password-new" class="mb-4"> <FormGroup label="New password" for="password-new" class="mb-4">
<InputGroup type="password" id="password-new" placeholder="Enter new password" autocomplete="new-password" appendButton={[{ icon: 'eye', description: 'Show password' }]} flat /> <InputGroup type="password" id="password-new" placeholder="Enter new password" autocomplete="new-password" appendButton={[{ icon: 'eye', description: 'Show password' }]} flat />
<FormHint as="small"> Your password must be 8-20 characters long, contain letters and numbers, and must not contain spaces, special characters, or emoji. </FormHint> <Strength input="#password-new" ariaLabel="New password strength">Use 8 to 20 characters, with letters and numbers.</Strength>
<div class="mt-2">
<div class="progress" style="height: 4px;">
<div class="progress-bar" id="password-strength" role="progressbar" style="width: 0%" aria-label="Password strength" aria-valuemin="0" aria-valuemax="5" aria-valuenow="0"></div>
</div>
<div class="text-secondary small mt-1" id="password-strength-text" aria-live="polite"></div>
</div>
</FormGroup> </FormGroup>
<FormGroup label="Confirm new password" for="password-confirm" class="mb-4"> <FormGroup label="Confirm new password" for="password-confirm" class="mb-4">
@@ -38,33 +32,8 @@ import FormGroup from '@ui/FormGroup.astro'
<!-- BEGIN CHANGE PASSWORD --> <!-- BEGIN CHANGE PASSWORD -->
<script> <script>
// Password show/hide toggles are wired generically by InputGroup.astro (data-password-toggle). // Password show/hide toggles are wired generically by InputGroup.astro (data-password-toggle).
// The strength meter is wired by tabler.js from data-bs-strength.
const newPasswordInput = document.getElementById('password-new') as HTMLInputElement | null const newPasswordInput = document.getElementById('password-new') as HTMLInputElement | null
const strengthBar = document.getElementById('password-strength')
const strengthText = document.getElementById('password-strength-text')
if (newPasswordInput && strengthBar && strengthText) {
newPasswordInput.addEventListener('input', function (this: HTMLInputElement) {
const password = this.value
let strength = 0
if (password.length >= 8) strength++
if (password.length >= 12) strength++
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++
if (/\d/.test(password)) strength++
if (/[^a-zA-Z0-9]/.test(password)) strength++
const percentage = (strength / 5) * 100
strengthBar.style.width = `${percentage}%`
strengthBar.setAttribute('aria-valuenow', String(strength))
const strengthLevels = ['bg-danger', 'bg-danger', 'bg-danger', 'bg-warning', 'bg-info', 'bg-success']
const strengthLabels = ['Weak', 'Weak', 'Weak', 'Fair', 'Good', 'Strong']
strengthBar.className = `progress-bar ${strengthLevels[strength]}`
strengthBar.setAttribute('aria-valuetext', password ? (strengthLabels[strength] ?? '') : 'Empty')
strengthText.textContent = password ? (strengthLabels[strength] ?? '') : ''
})
}
const confirmPasswordInput = document.getElementById('password-confirm') as HTMLInputElement | null const confirmPasswordInput = document.getElementById('password-confirm') as HTMLInputElement | null
const matchError = document.getElementById('password-match-error') const matchError = document.getElementById('password-match-error')
+4
View File
@@ -354,6 +354,10 @@
"title": "Color check", "title": "Color check",
"url": "/ui/forms/color-check" "url": "/ui/forms/color-check"
}, },
{
"title": "Password strength",
"url": "/ui/forms/strength"
},
{ {
"title": "Validation", "title": "Validation",
"url": "/ui/forms/validation" "url": "/ui/forms/validation"
+4
View File
@@ -188,6 +188,10 @@
"url": "pagination.html", "url": "pagination.html",
"title": "Pagination" "title": "Pagination"
}, },
"password-strength": {
"url": "password-strength.html",
"title": "Password strength"
},
"patterns": { "patterns": {
"url": "patterns.html", "url": "patterns.html",
"title": "Patterns", "title": "Patterns",
+42
View File
@@ -0,0 +1,42 @@
---
// Strength (.strength): a segmented password meter, filled by core/js/src/strength.ts
// from the data attributes this component emits. Renders the meter and, unless
// `showText` is off, the label element the component writes the level into.
interface Props {
/** number of bars; they fill in proportion to the level */
segments?: number
/** data-bs-input: selector of the password field, when it is not a sibling */
input?: string | undefined
/** data-bs-min-length: the length the built-in scoring counts as long enough */
minLength?: number | undefined
/** data-bs-thresholds: score bounds for weak, fair and good */
thresholds?: number[] | undefined
/** data-bs-messages: the label for each level */
messages?: Record<string, string> | undefined
/** render the .strength-text label under the meter; its slot content shows while the field is empty */
showText?: boolean
/** accessible name of the meter — translate it together with the messages */
ariaLabel?: string
class?: string
/** classes on the label element */
textClass?: string
/** remaining attributes (id, data-*, …) are forwarded to the meter */
[key: string]: unknown
}
const { segments = 4, input, minLength, thresholds, messages, showText = true, ariaLabel = 'Password strength', class: className, textClass, ...rest } = Astro.props
const bars = Array.from({ length: segments }, (_, index) => index)
---
<div class:list={['strength', className]} data-bs-strength data-bs-input={input} data-bs-min-length={minLength} data-bs-thresholds={thresholds ? JSON.stringify(thresholds) : undefined} data-bs-messages={messages ? JSON.stringify(messages) : undefined} aria-label={ariaLabel} {...rest}>
{bars.map(() => <span class="strength-segment" />)}
</div>
{
showText && (
<div class:list={['strength-text', textClass]}>
<slot />
</div>
)
}