stimulus-password-strength 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a0150f9398507c2a875986766bf55719e1301a68cc66a12591f1796354fc872f
4
+ data.tar.gz: c469c7ec416cff1eb8dcb78f3a0a5cdd961a9d60110a317e1f6d9836727b442d
5
+ SHA512:
6
+ metadata.gz: dc3c6f68e8ede33665fec285d0079d4cee0020b823b441bc8be08ea25c638862b656746ab5c9b359be1d3e055346c4a639c5ce4d9249c7e073cacb6f0c454189
7
+ data.tar.gz: 8118612aa2c405d37dd6460a72aac666faec1317f8f6b0d98bcbcc6d039201f57a90fed64d36e3f4dcdd2e8fce198dae5abbdbca05ed8031869b19e62a50a420
data/.rubocop.yml ADDED
@@ -0,0 +1,41 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.2
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+ Exclude:
6
+ - "test/tmp/**/*"
7
+
8
+ Layout/LineLength:
9
+ Max: 150
10
+
11
+ Metrics/AbcSize:
12
+ Enabled: false
13
+
14
+ Metrics/BlockLength:
15
+ Exclude:
16
+ - "test/**/*"
17
+ Max: 30
18
+
19
+ Metrics/CyclomaticComplexity:
20
+ Enabled: false
21
+
22
+ Metrics/MethodLength:
23
+ Enabled: false
24
+
25
+ Metrics/ModuleLength:
26
+ Enabled: false
27
+
28
+ Metrics/PerceivedComplexity:
29
+ Enabled: false
30
+
31
+ Style/Documentation:
32
+ Enabled: false
33
+
34
+ Style/FrozenStringLiteralComment:
35
+ Enabled: false
36
+
37
+ Style/FormatStringToken:
38
+ Enabled: false
39
+
40
+ Style/StringLiterals:
41
+ Enabled: false
data/AGENTS.md ADDED
@@ -0,0 +1,71 @@
1
+ # AGENTS.md
2
+
3
+ Use this file when an AI coding agent is integrating `stimulus-password-strength` into a host Rails app.
4
+
5
+ ## Goal
6
+
7
+ Treat this gem as a UI layer for password UX, not as password security policy.
8
+
9
+ The integration should:
10
+
11
+ - reduce signup friction
12
+ - keep the meter visible even with 1Password/LastPass overlays
13
+ - preserve backend validation until the host app intentionally migrates it
14
+
15
+ ## Recommended Rollout
16
+
17
+ 1. Add the gem and run:
18
+
19
+ ```bash
20
+ bundle install
21
+ bin/rails generate stimulus_password_strength:install
22
+ ```
23
+
24
+ 2. Use the generated `PasswordPolicy` as the shared source of truth for backend rules and UI requirements.
25
+ 3. Replace the main password field with `password_strength_field`, passing `requirements: PasswordPolicy.requirements`.
26
+ 4. Update all user-facing password views. Minimum:
27
+ - signup / registration
28
+ - password reset / change password
29
+ 5. If the app has additional password-setting flows, update them too:
30
+ - invitation acceptance
31
+ - onboarding set password
32
+ - admin-created account activation
33
+ - forced password change
34
+ 6. Do not remove `password_confirmation` if the host app still requires it.
35
+ 7. Do not change backend validation during the first rollout unless validation is already wired to `PasswordPolicy`.
36
+ 8. Roll out the UI first, then decide separately whether to simplify backend policy.
37
+
38
+ ## Host App Checks
39
+
40
+ Before changing any auth flow, verify:
41
+
42
+ 1. Does the app require `password_confirmation`?
43
+ 2. Does the app enforce regex rules such as uppercase/lowercase/digit?
44
+ 3. Are there custom validation messages that would drift from the new UI?
45
+ 4. Are there additional password flows outside signup and reset?
46
+ 5. Does the host app use a custom design system that should override helper classes?
47
+
48
+ ## What Not to Change Automatically
49
+
50
+ 1. Do not remove `password_confirmation` without reviewing validations, tests, and reset flow behavior.
51
+ 2. Do not change password policy just because the meter shows `good` or `strong`.
52
+ 3. Do not define `requirements` separately from `PasswordPolicy`.
53
+ 4. Do not hardcode hints such as `Must contain uppercase...` unless the backend actually enforces them.
54
+ 5. Do not assume Tailwind classes applied dynamically from JS will work in every host app.
55
+
56
+ ## Minimal Smoke Test
57
+
58
+ 1. Signup: weak password -> meter, toggle, and backend validation all behave correctly.
59
+ 2. Signup with 1Password/LastPass enabled: meter and requirements remain visible.
60
+ 3. Password reset: the same component behaves consistently in the second flow.
61
+ 4. Browser autofill: meter and requirements refresh after typing or autofill.
62
+ 5. Mobile viewport: toggle does not overlap text or password manager icons.
63
+
64
+ ## Policy Reminder
65
+
66
+ This gem does not replace:
67
+
68
+ - model validation
69
+ - rate limiting
70
+ - anti-abuse controls
71
+ - password reset security
data/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ ## 0.1.2
4
+
5
+ - switched gem packaging to `git ls-files`
6
+ - added a lightweight RuboCop setup for the gem repository
7
+ - simplified CI to a single `main.yml` workflow with Ruby lint, Ruby tests, and JS tests
8
+ - aligned repository tooling with the release workflow used in `price_scanner`
9
+
10
+ ## 0.1.1
11
+
12
+ - first public RubyGems release
13
+ - English README for public consumers
14
+ - agent-specific installation guidance moved to `AGENTS.md` and `CLAUDE.md`
15
+ - CI reduced to the minimum useful cross-platform matrix: Ruby 3.2 on Linux and macOS
16
+ - lockfile updated for Linux and generic Ruby platforms
17
+
18
+ ## 0.1.0
19
+
20
+ - first public release candidate
21
+ - Rails 8 engine with helper, partial, installer and importmap support
22
+ - Stimulus password strength controller with show/hide toggle
23
+ - vendored `zxcvbn.js` for host apps without Node
24
+ - explicit `PasswordPolicy` pattern for syncing backend rules with UI requirements
25
+ - Ruby tests for helper rendering, generator behavior and dummy app smoke flow
26
+ - lightweight JS tests for controller behavior
27
+ - public README rewritten in English
28
+ - agent-specific integration instructions moved to `AGENTS.md` and `CLAUDE.md`
data/CLAUDE.md ADDED
@@ -0,0 +1,11 @@
1
+ # CLAUDE.md
2
+
3
+ For integration guidance, treat [AGENTS.md](AGENTS.md) as the canonical source.
4
+
5
+ ## Claude-Specific Notes
6
+
7
+ 1. Start with a UI-only rollout unless the host app explicitly asks to simplify backend password policy.
8
+ 2. Prefer using the generated `PasswordPolicy` as the source of truth for both model validation and gem requirements.
9
+ 3. Update every user-facing password flow, not just signup.
10
+ 4. If the host app still requires `password_confirmation`, keep it until validations and tests are migrated deliberately.
11
+ 5. After integration, run a smoke test for signup, password reset, autofill, and password manager overlay behavior.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Justyna Wojtczak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,82 @@
1
+ # Publish Checklist
2
+
3
+ ## Goal
4
+
5
+ Ship `stimulus-password-strength` as a reusable Rails 8+ gem with verified install path, stable UI API and enough automated coverage to publish safely.
6
+
7
+ ## Tasks
8
+
9
+ ### 1. Repo hygiene
10
+
11
+ - [x] remove built gem artifact from version control flow
12
+ - [x] ignore `node_modules` and built `.gem` files
13
+ - [x] ensure gemspec ships README and LICENSE
14
+
15
+ **DoD**
16
+ - `git status` shows no accidental build outputs tracked
17
+ - `.gitignore` blocks local artifacts
18
+ - built gem includes docs/license files
19
+
20
+ ### 2. Public release metadata
21
+
22
+ - [x] add release notes file
23
+ - [x] enable RubyGems MFA metadata
24
+ - [x] make README default to published install flow
25
+
26
+ **DoD**
27
+ - `CHANGELOG.md` exists
28
+ - gemspec includes `rubygems_mfa_required`
29
+ - README installation example does not depend on local `path:`
30
+
31
+ ### 3. Helper and rendering coverage
32
+
33
+ - [x] test rendered markup from `password_strength_field`
34
+ - [x] verify i18n labels and requirement serialization
35
+ - [x] verify invalid requirement rules fail fast
36
+
37
+ **DoD**
38
+ - helper test covers main HTML contract consumed by Stimulus
39
+ - tests assert both default and localized output
40
+ - unsupported requirements raise a clear error
41
+
42
+ ### 4. Installer coverage
43
+
44
+ - [x] test importmap pin insertion
45
+ - [x] test Stimulus controller registration
46
+ - [x] test initializer creation
47
+ - [x] test `PasswordPolicy` template creation
48
+ - [x] test idempotency
49
+
50
+ **DoD**
51
+ - generator test proves a fresh host app gets all required files
52
+ - rerunning installer does not duplicate pins or controller registration
53
+
54
+ ### 5. Dummy app smoke test
55
+
56
+ - [x] add minimal Rails dummy app
57
+ - [x] render a form using the gem helper
58
+ - [x] assert response contains the expected password field contract
59
+
60
+ **DoD**
61
+ - dummy app boots in test environment
62
+ - integration test hits a real route and renders the component successfully
63
+
64
+ ### 6. JS behavior coverage
65
+
66
+ - [x] add lightweight Node-based tests for the Stimulus controller
67
+ - [x] test toggle behavior
68
+ - [x] test requirement label transitions
69
+ - [x] test hidden state for empty password
70
+
71
+ **DoD**
72
+ - JS tests run without bundling the host app
73
+ - core controller UX behavior is covered by automation
74
+
75
+ ### 7. Verification
76
+
77
+ - [x] run Ruby test suite
78
+ - [x] run JS test suite
79
+
80
+ **DoD**
81
+ - both suites pass locally
82
+ - release candidate is blocked only by product decisions, not missing test scaffolding
data/README.md ADDED
@@ -0,0 +1,182 @@
1
+ # stimulus-password-strength
2
+
3
+ Importmap-friendly password strength field for Rails 8+ with Stimulus, `zxcvbn`, and Tailwind-friendly markup.
4
+
5
+ ## Product Goal
6
+
7
+ Reduce signup abandonment by improving password UX:
8
+
9
+ - one password field with `show/hide`
10
+ - real-time strength meter
11
+ - requirements placed above the input so they stay visible with 1Password/LastPass overlays
12
+ - no Node.js required in the host app
13
+ - simpler signup flow while keeping security standards and sound UX practices
14
+
15
+ ## What the Gem Includes
16
+
17
+ - Rails engine: `StimulusPasswordStrength::Engine`
18
+ - Stimulus controller: `password-strength`
19
+ - vendored `zxcvbn.js`
20
+ - helper: `password_strength_field`
21
+ - partial: `_field.html.erb`
22
+ - installer: `rails g stimulus_password_strength:install`
23
+ - default i18n files: `en`, `pl`
24
+
25
+ ## Installation
26
+
27
+ Add the gem to your app:
28
+
29
+ ```ruby
30
+ gem "stimulus-password-strength"
31
+ ```
32
+
33
+ Then run:
34
+
35
+ ```bash
36
+ bundle install
37
+ bin/rails generate stimulus_password_strength:install
38
+ ```
39
+
40
+ The installer:
41
+
42
+ - adds importmap pins to `config/importmap.rb`
43
+ - registers the controller in `app/javascript/controllers/index.js`
44
+ - creates `config/initializers/stimulus_password_strength.rb`
45
+ - creates `app/lib/password_policy.rb` as the single source of truth for the rules shown in the UI
46
+
47
+ ## Usage
48
+
49
+ ```erb
50
+ <%= form_with(model: @user) do |form| %>
51
+ <%= password_strength_field form, :password,
52
+ placeholder: "Minimum 12 characters",
53
+ requirements: PasswordPolicy.requirements %>
54
+ <% end %>
55
+ ```
56
+
57
+ With custom labels:
58
+
59
+ ```erb
60
+ <%= password_strength_field form, :password,
61
+ strength_labels: { weak: "Weak", fair: "Fair", good: "Good", strong: "Strong" },
62
+ toggle_labels: { show: "Show", hide: "Hide" } %>
63
+ ```
64
+
65
+ ## Password Policy
66
+
67
+ The installer creates a sample [password_policy.rb.tt](/Users/justi/projects_prod/stimulus-password-strength/lib/generators/stimulus_password_strength/install/templates/password_policy.rb.tt) file that should become the shared source of truth for:
68
+
69
+ - backend model validation
70
+ - requirements rendered by the gem
71
+
72
+ Example host app validation:
73
+
74
+ ```ruby
75
+ # app/models/user.rb
76
+ validates :password, length: { minimum: PasswordPolicy::MIN_LENGTH }, allow_nil: true
77
+ ```
78
+
79
+ Example view usage:
80
+
81
+ ```erb
82
+ <%= password_strength_field form, :password,
83
+ requirements: PasswordPolicy.requirements %>
84
+ ```
85
+
86
+ `min_length` can use dynamic live copy from `PasswordPolicy`, for example:
87
+
88
+ ```ruby
89
+ REQUIREMENTS = [
90
+ {
91
+ rule: :min_length,
92
+ value: MIN_LENGTH,
93
+ label: "At least #{MIN_LENGTH} characters",
94
+ remaining_singular: "Type 1 more character",
95
+ remaining_plural: "Type %{count} more characters",
96
+ met_label: "#{MIN_LENGTH}+ chars"
97
+ }
98
+ ].freeze
99
+ ```
100
+
101
+ The gem does not try to infer rules from the model and does not add hidden fallbacks for `requirements`. The host app must pass policy explicitly from `PasswordPolicy`.
102
+
103
+ ## Configuration
104
+
105
+ `config/initializers/stimulus_password_strength.rb`:
106
+
107
+ ```ruby
108
+ StimulusPasswordStrength.configure do |config|
109
+ config.input_class = "w-full rounded-md border px-3 py-2 pr-16"
110
+ config.text_style = "min-width: 2.5rem; text-align: right; white-space: nowrap;"
111
+ config.status_row_class = "flex min-h-5 items-center gap-2"
112
+ config.requirements_style = "min-height: 1rem;"
113
+ config.requirement_pending_style = "color: #6b7280;"
114
+ config.requirement_met_style = "color: #047857;"
115
+ config.requirement_unmet_style = "color: #b91c1c;"
116
+
117
+ config.bar_colors = {
118
+ weak: "#f87171",
119
+ fair: "#fbbf24",
120
+ good: "#22c55e",
121
+ strong: "#059669"
122
+ }
123
+ end
124
+ ```
125
+
126
+ Adding more languages is standard Rails I18n: add another locale file in [config/locales](/Users/justi/projects_prod/stimulus-password-strength/config/locales).
127
+
128
+ ## Post-Install Checklist
129
+
130
+ 1. Signup: weak password -> backend validation still works.
131
+ 2. Signup: `requirements` match `PasswordPolicy` and model validation.
132
+ 3. Signup: `Show/Hide` toggle works on mobile and desktop.
133
+ 4. Password reset: meter, requirements, and toggle behave the same way as signup.
134
+ 5. Password autofill: the strength meter and requirements refresh correctly.
135
+ 6. JS or `zxcvbn` failure: the form still allows submission.
136
+ 7. i18n: `show/hide/weak/fair/good/strong` labels are correct for the current locale.
137
+
138
+ ## Agent Guidance
139
+
140
+ If you are installing this gem through an AI coding agent, use:
141
+
142
+ - [AGENTS.md](AGENTS.md) for general agent instructions
143
+ - [CLAUDE.md](CLAUDE.md) for Claude-specific workflow notes
144
+
145
+ These files cover rollout order, host-app validation checks, smoke tests, and what should not be changed automatically.
146
+
147
+ ## Example Adaptation: `linked_flow`
148
+
149
+ The gem was also used in `../linked_flow` as an example of a more opinionated UX simplification:
150
+
151
+ - signup and password reset work without `password_confirmation`
152
+ - the UI uses `password_strength_field`
153
+ - backend validation and UI both use a shared `PasswordPolicy` with minimum password length
154
+
155
+ Recommended rollout order:
156
+
157
+ 1. Add the gem as UI-only.
158
+ 2. Switch signup and reset views to `password_strength_field`.
159
+ 3. Only then decide whether to simplify backend policy and business tests.
160
+
161
+ ## Nice Follow-Ups After `0.1.0`
162
+
163
+ 1. Generator for JS test scaffolding in the host app.
164
+ 2. Wider CI coverage for Rails `8.0` and `8.1`.
165
+ 3. `confirmation: true/false` helper option.
166
+ 4. Public analytics event documentation for signup funnel instrumentation.
167
+ 5. More examples for integrating with host app design systems.
168
+
169
+ ## Development
170
+
171
+ ```bash
172
+ cd /Users/justi/projects_prod/stimulus-password-strength
173
+ bundle install
174
+ npm install
175
+ bundle exec rake test
176
+ npm test
177
+ ```
178
+
179
+ ## Release Hygiene
180
+
181
+ - full publication checklist: [PUBLISH_CHECKLIST.md](PUBLISH_CHECKLIST.md)
182
+ - change history: [CHANGELOG.md](CHANGELOG.md)
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
@@ -0,0 +1,193 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["input", "toggle", "statusRow", "strengthTrack", "strengthBar", "strengthText", "requirement", "showIcon", "hideIcon"]
5
+
6
+ connect() {
7
+ this.syncToggleState()
8
+ this.evaluate()
9
+ this.loadZxcvbn()
10
+ }
11
+
12
+ async loadZxcvbn() {
13
+ try {
14
+ const mod = await import("zxcvbn")
15
+ this.zxcvbn = mod.default || mod
16
+ this.evaluate()
17
+ } catch (error) {
18
+ this.zxcvbn = null
19
+ }
20
+ }
21
+
22
+ toggle() {
23
+ const isPassword = this.inputTarget.type === "password"
24
+ this.inputTarget.type = isPassword ? "text" : "password"
25
+ this.syncToggleState()
26
+ }
27
+
28
+ evaluate() {
29
+ const password = this.inputTarget.value
30
+
31
+ if (password.length === 0) {
32
+ this.statusRowTarget.style.visibility = "hidden"
33
+ this.strengthTrackTarget.style.visibility = "hidden"
34
+ this.strengthBarTarget.style.width = "0%"
35
+ this.strengthBarTarget.style.backgroundColor = ""
36
+ this.strengthTextTarget.textContent = ""
37
+ this.strengthTextTarget.style.color = ""
38
+ this.updateRequirements(password)
39
+ return
40
+ }
41
+
42
+ this.statusRowTarget.style.visibility = "visible"
43
+ this.strengthTrackTarget.style.visibility = "visible"
44
+ const score = this.scoreFor(password)
45
+ const widths = [10, 25, 50, 75, 100]
46
+ const labels = ["weak", "weak", "fair", "good", "strong"]
47
+
48
+ this.strengthBarTarget.style.width = `${widths[score]}%`
49
+ this.strengthBarTarget.className = this.barClasses()
50
+ this.strengthBarTarget.style.backgroundColor = this.barColor(score)
51
+ this.strengthTextTarget.textContent = this.label(labels[score])
52
+ this.strengthTextTarget.className = this.textClasses()
53
+ this.strengthTextTarget.style.color = this.textColor(score)
54
+ this.updateRequirements(password)
55
+ }
56
+
57
+ scoreFor(password) {
58
+ if (this.zxcvbn) {
59
+ return this.zxcvbn(password).score
60
+ }
61
+
62
+ // Fallback when zxcvbn cannot be loaded (offline/asset issue).
63
+ let score = 0
64
+ if (password.length >= 8) score += 1
65
+ if (password.length >= 12) score += 1
66
+ if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score += 1
67
+ if (/\d/.test(password) || /[^A-Za-z0-9]/.test(password)) score += 1
68
+ return Math.min(score, 4)
69
+ }
70
+
71
+ label(key) {
72
+ const map = {
73
+ weak: this.element.dataset.passwordStrengthWeakLabel,
74
+ fair: this.element.dataset.passwordStrengthFairLabel,
75
+ good: this.element.dataset.passwordStrengthGoodLabel,
76
+ strong: this.element.dataset.passwordStrengthStrongLabel
77
+ }
78
+
79
+ return map[key] || key
80
+ }
81
+
82
+ barClasses() {
83
+ return this.element.dataset.passwordStrengthBarBaseClass || "h-full rounded-full transition-all duration-300"
84
+ }
85
+
86
+ textClasses() {
87
+ return this.element.dataset.passwordStrengthTextBaseClass || "text-xs"
88
+ }
89
+
90
+ barColor(score) {
91
+ const colors = [
92
+ this.element.dataset.passwordStrengthWeakBarColor || "#f87171",
93
+ this.element.dataset.passwordStrengthWeakBarColor || "#f87171",
94
+ this.element.dataset.passwordStrengthFairBarColor || "#fbbf24",
95
+ this.element.dataset.passwordStrengthGoodBarColor || "#22c55e",
96
+ this.element.dataset.passwordStrengthStrongBarColor || "#059669"
97
+ ]
98
+
99
+ return colors[score]
100
+ }
101
+
102
+ textColor(score) {
103
+ const colors = [
104
+ this.element.dataset.passwordStrengthWeakTextColor || "#ef4444",
105
+ this.element.dataset.passwordStrengthWeakTextColor || "#ef4444",
106
+ this.element.dataset.passwordStrengthFairTextColor || "#d97706",
107
+ this.element.dataset.passwordStrengthGoodTextColor || "#16a34a",
108
+ this.element.dataset.passwordStrengthStrongTextColor || "#047857"
109
+ ]
110
+
111
+ return colors[score]
112
+ }
113
+
114
+ syncToggleState() {
115
+ const isPassword = this.inputTarget.type === "password"
116
+ const label = isPassword
117
+ ? this.toggleTarget.dataset.showLabel
118
+ : this.toggleTarget.dataset.hideLabel
119
+
120
+ this.toggleTarget.setAttribute("aria-label", label)
121
+ this.toggleTarget.setAttribute("title", label)
122
+
123
+ if (this.hasShowIconTarget) this.showIconTarget.hidden = !isPassword
124
+ if (this.hasHideIconTarget) this.hideIconTarget.hidden = isPassword
125
+ }
126
+
127
+ updateRequirements(password) {
128
+ this.requirementTargets.forEach((element) => {
129
+ const isMet = this.requirementMet(element, password)
130
+
131
+ if (password.length === 0) {
132
+ element.setAttribute("aria-hidden", "false")
133
+ element.innerHTML = this.escapeHtml(element.dataset.label)
134
+ element.style.cssText = `${element.dataset.pendingStyle}; visibility: visible;`
135
+ return
136
+ }
137
+
138
+ element.setAttribute("aria-hidden", "false")
139
+ element.innerHTML = isMet
140
+ ? this.metRequirementMarkup(element)
141
+ : this.escapeHtml(this.requirementLabel(element, password))
142
+ element.style.cssText = isMet
143
+ ? `${element.dataset.metStyle}; visibility: visible;`
144
+ : `${element.dataset.unmetStyle}; visibility: visible;`
145
+ })
146
+ }
147
+
148
+ requirementMet(element, password) {
149
+ switch (element.dataset.rule) {
150
+ case "min_length":
151
+ return password.length >= Number(element.dataset.value)
152
+ case "uppercase":
153
+ return /[A-Z]/.test(password)
154
+ default:
155
+ return false
156
+ }
157
+ }
158
+
159
+ requirementLabel(element, password) {
160
+ switch (element.dataset.rule) {
161
+ case "min_length": {
162
+ const required = Number(element.dataset.value)
163
+ const remaining = Math.max(required - password.length, 0)
164
+
165
+ if (remaining === 1 && element.dataset.remainingSingular) {
166
+ return element.dataset.remainingSingular
167
+ }
168
+
169
+ if (remaining > 1 && element.dataset.remainingPlural) {
170
+ return element.dataset.remainingPlural.replace("%{count}", remaining)
171
+ }
172
+
173
+ return element.dataset.label
174
+ }
175
+ default:
176
+ return element.dataset.label
177
+ }
178
+ }
179
+
180
+ metRequirementMarkup(element) {
181
+ const label = this.escapeHtml(element.dataset.metLabel || element.dataset.label)
182
+ return `<span style="display: inline-flex; align-items: center; gap: 0.25rem;"><span aria-hidden="true">✓</span><span>${label}</span></span>`
183
+ }
184
+
185
+ escapeHtml(value) {
186
+ return String(value)
187
+ .replaceAll("&", "&amp;")
188
+ .replaceAll("<", "&lt;")
189
+ .replaceAll(">", "&gt;")
190
+ .replaceAll('"', "&quot;")
191
+ .replaceAll("'", "&#39;")
192
+ }
193
+ }