mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Add a password strength meter (#3054)
This commit is contained in:
1 parent
5d5be36332
commit
9669c0a9a9
16 files changed
+990
-34
No files matched your search
@@ -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`.
|
||||
@@ -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
|
||||
@@ -10,6 +10,7 @@ import './src/tab'
|
||||
import './src/toast'
|
||||
import './src/sortable'
|
||||
import './src/sparkline'
|
||||
import './src/strength'
|
||||
|
||||
// Re-export everything from bootstrap.ts (single source of truth)
|
||||
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 Sortable } from './src/sortable'
|
||||
export { default as Sparkline } from './src/sparkline'
|
||||
export { default as Strength } from './src/strength'
|
||||
export { default as SwitchIcon } from './src/switch-icon'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -67,6 +67,7 @@
|
||||
@forward 'ui/forms/form-custom';
|
||||
@forward 'ui/forms/form-check';
|
||||
@forward 'ui/forms/validation';
|
||||
@forward 'ui/forms/strength';
|
||||
@forward 'ui/legend';
|
||||
@forward 'ui/lists';
|
||||
@forward 'ui/loaders';
|
||||
|
||||
@@ -1873,6 +1873,23 @@ $progressbg-padding-x: 0.5rem !default;
|
||||
$progressbg-value-padding-start: 2rem !default;
|
||||
$progress-separated-ring-width: 2px !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;
|
||||
// scss-docs-end progress-variables
|
||||
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
@@ -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>
|
||||
@@ -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,6 +2,7 @@
|
||||
import FormFooter from '@ui/FormFooter.astro'
|
||||
import InputGroup from '@ui/InputGroup.astro'
|
||||
import FormGroup from '@ui/FormGroup.astro'
|
||||
import Strength from '@ui/Strength.astro'
|
||||
import CardTitle from '@ui/CardTitle.astro'
|
||||
---
|
||||
|
||||
@@ -17,6 +18,7 @@ import CardTitle from '@ui/CardTitle.astro'
|
||||
</FormGroup>
|
||||
<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 />
|
||||
<Strength />
|
||||
</FormGroup>
|
||||
<div class="mb-3">
|
||||
<label class="form-check">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import Strength from '@ui/Strength.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" />
|
||||
</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">
|
||||
<textarea class="form-control" id="fe5-textarea-autosize" data-bs-toggle="autosize" placeholder="Type something…"></textarea>
|
||||
</FormGroup>
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
import FormHint from '@ui/FormHint.astro'
|
||||
import Button from '@ui/Button.astro'
|
||||
import InputGroup from '@ui/InputGroup.astro'
|
||||
import FormGroup from '@ui/FormGroup.astro'
|
||||
import Strength from '@ui/Strength.astro'
|
||||
---
|
||||
|
||||
<div class="modal-header">
|
||||
@@ -18,13 +18,7 @@ import FormGroup from '@ui/FormGroup.astro'
|
||||
|
||||
<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 />
|
||||
<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>
|
||||
<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>
|
||||
<Strength input="#password-new" ariaLabel="New password strength">Use 8 to 20 characters, with letters and numbers.</Strength>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Confirm new password" for="password-confirm" class="mb-4">
|
||||
@@ -38,33 +32,8 @@ import FormGroup from '@ui/FormGroup.astro'
|
||||
<!-- BEGIN CHANGE PASSWORD -->
|
||||
<script>
|
||||
// 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 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 matchError = document.getElementById('password-match-error')
|
||||
|
||||
|
||||
@@ -354,6 +354,10 @@
|
||||
"title": "Color check",
|
||||
"url": "/ui/forms/color-check"
|
||||
},
|
||||
{
|
||||
"title": "Password strength",
|
||||
"url": "/ui/forms/strength"
|
||||
},
|
||||
{
|
||||
"title": "Validation",
|
||||
"url": "/ui/forms/validation"
|
||||
|
||||
@@ -188,6 +188,10 @@
|
||||
"url": "pagination.html",
|
||||
"title": "Pagination"
|
||||
},
|
||||
"password-strength": {
|
||||
"url": "password-strength.html",
|
||||
"title": "Password strength"
|
||||
},
|
||||
"patterns": {
|
||||
"url": "patterns.html",
|
||||
"title": "Patterns",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user