diff --git a/README.md b/README.md
index 84ff8cc..c2b980a 100644
--- a/README.md
+++ b/README.md
@@ -161,6 +161,8 @@ The bundle uses these CSS classes that you can style:
- `.input-group.input-group-merge` - Container
- `.input-group-text.cursor-pointer` - Toggle button
- `.icon-base` - Icon classes
+- `.icon-hidden` / `.icon-visible` - Eye icons (`.icon-visible` is hidden by default in `toggle_password.css`)
+- `.is-password-visible` - Added to the toggle button when the password is shown (CSP-safe; do not use inline `style.display`)
Example custom styles:
diff --git a/demo/symfony7/templates/demo/form.html.twig b/demo/symfony7/templates/demo/form.html.twig
index 406aa69..a40dd26 100644
--- a/demo/symfony7/templates/demo/form.html.twig
+++ b/demo/symfony7/templates/demo/form.html.twig
@@ -58,6 +58,19 @@
height: 1.25rem;
transition: opacity 0.2s ease-in-out;
}
+ /* One eye at a time (CSP-safe; mirrors toggle_password.css) */
+ .form-password-toggle .icon-visible,
+ .input-group-text.cursor-pointer .icon-visible {
+ display: none;
+ }
+ .form-password-toggle .is-password-visible .icon-hidden,
+ .input-group-text.cursor-pointer.is-password-visible .icon-hidden {
+ display: none;
+ }
+ .form-password-toggle .is-password-visible .icon-visible,
+ .input-group-text.cursor-pointer.is-password-visible .icon-visible {
+ display: block;
+ }
.alert {
margin-top: 20px;
}
diff --git a/demo/symfony7/templates/form/toggle_password_widget.html.twig b/demo/symfony7/templates/form/toggle_password_widget.html.twig
index 0b68f58..d250868 100644
--- a/demo/symfony7/templates/form/toggle_password_widget.html.twig
+++ b/demo/symfony7/templates/form/toggle_password_widget.html.twig
@@ -1,7 +1,8 @@
{%- block toggle_password_widget -%}
{#
Template for password field with visibility toggle (Demo version)
- This is a simplified version that works without Symfony UX Icon
+ This is a simplified version that works without Symfony UX Icon.
+ Icon visibility uses is-password-visible + toggle_password.css (CSP-safe).
#}
@@ -13,24 +14,19 @@
aria-label="{{ visible_label|default('Show password') }}"
onclick="
const input = this.previousElementSibling;
- const iconHidden = this.querySelector('.icon-hidden');
- const iconVisible = this.querySelector('.icon-visible');
if (input.type === 'password') {
input.type = 'text';
- iconHidden.classList.add('d-none');
- iconVisible.classList.remove('d-none');
+ this.classList.add('is-password-visible');
this.setAttribute('aria-label', '{{ hidden_label|default('Hide password') }}');
} else {
input.type = 'password';
- iconHidden.classList.remove('d-none');
- iconVisible.classList.add('d-none');
+ this.classList.remove('is-password-visible');
this.setAttribute('aria-label', '{{ visible_label|default('Show password') }}');
}
">
-
+
{%- endblock toggle_password_widget -%}
-
diff --git a/demo/symfony8-php85/templates/demo/form.html.twig b/demo/symfony8-php85/templates/demo/form.html.twig
index 406aa69..a40dd26 100644
--- a/demo/symfony8-php85/templates/demo/form.html.twig
+++ b/demo/symfony8-php85/templates/demo/form.html.twig
@@ -58,6 +58,19 @@
height: 1.25rem;
transition: opacity 0.2s ease-in-out;
}
+ /* One eye at a time (CSP-safe; mirrors toggle_password.css) */
+ .form-password-toggle .icon-visible,
+ .input-group-text.cursor-pointer .icon-visible {
+ display: none;
+ }
+ .form-password-toggle .is-password-visible .icon-hidden,
+ .input-group-text.cursor-pointer.is-password-visible .icon-hidden {
+ display: none;
+ }
+ .form-password-toggle .is-password-visible .icon-visible,
+ .input-group-text.cursor-pointer.is-password-visible .icon-visible {
+ display: block;
+ }
.alert {
margin-top: 20px;
}
diff --git a/demo/symfony8-php85/templates/form/toggle_password_widget.html.twig b/demo/symfony8-php85/templates/form/toggle_password_widget.html.twig
index 0b68f58..d250868 100644
--- a/demo/symfony8-php85/templates/form/toggle_password_widget.html.twig
+++ b/demo/symfony8-php85/templates/form/toggle_password_widget.html.twig
@@ -1,7 +1,8 @@
{%- block toggle_password_widget -%}
{#
Template for password field with visibility toggle (Demo version)
- This is a simplified version that works without Symfony UX Icon
+ This is a simplified version that works without Symfony UX Icon.
+ Icon visibility uses is-password-visible + toggle_password.css (CSP-safe).
#}
@@ -13,24 +14,19 @@
aria-label="{{ visible_label|default('Show password') }}"
onclick="
const input = this.previousElementSibling;
- const iconHidden = this.querySelector('.icon-hidden');
- const iconVisible = this.querySelector('.icon-visible');
if (input.type === 'password') {
input.type = 'text';
- iconHidden.classList.add('d-none');
- iconVisible.classList.remove('d-none');
+ this.classList.add('is-password-visible');
this.setAttribute('aria-label', '{{ hidden_label|default('Hide password') }}');
} else {
input.type = 'password';
- iconHidden.classList.remove('d-none');
- iconVisible.classList.add('d-none');
+ this.classList.remove('is-password-visible');
this.setAttribute('aria-label', '{{ visible_label|default('Show password') }}');
}
">
-
+
{%- endblock toggle_password_widget -%}
-
diff --git a/demo/symfony8/templates/demo/form.html.twig b/demo/symfony8/templates/demo/form.html.twig
index 406aa69..a40dd26 100644
--- a/demo/symfony8/templates/demo/form.html.twig
+++ b/demo/symfony8/templates/demo/form.html.twig
@@ -58,6 +58,19 @@
height: 1.25rem;
transition: opacity 0.2s ease-in-out;
}
+ /* One eye at a time (CSP-safe; mirrors toggle_password.css) */
+ .form-password-toggle .icon-visible,
+ .input-group-text.cursor-pointer .icon-visible {
+ display: none;
+ }
+ .form-password-toggle .is-password-visible .icon-hidden,
+ .input-group-text.cursor-pointer.is-password-visible .icon-hidden {
+ display: none;
+ }
+ .form-password-toggle .is-password-visible .icon-visible,
+ .input-group-text.cursor-pointer.is-password-visible .icon-visible {
+ display: block;
+ }
.alert {
margin-top: 20px;
}
diff --git a/demo/symfony8/templates/form/toggle_password_widget.html.twig b/demo/symfony8/templates/form/toggle_password_widget.html.twig
index 0b68f58..d250868 100644
--- a/demo/symfony8/templates/form/toggle_password_widget.html.twig
+++ b/demo/symfony8/templates/form/toggle_password_widget.html.twig
@@ -1,7 +1,8 @@
{%- block toggle_password_widget -%}
{#
Template for password field with visibility toggle (Demo version)
- This is a simplified version that works without Symfony UX Icon
+ This is a simplified version that works without Symfony UX Icon.
+ Icon visibility uses is-password-visible + toggle_password.css (CSP-safe).
#}
@@ -13,24 +14,19 @@
aria-label="{{ visible_label|default('Show password') }}"
onclick="
const input = this.previousElementSibling;
- const iconHidden = this.querySelector('.icon-hidden');
- const iconVisible = this.querySelector('.icon-visible');
if (input.type === 'password') {
input.type = 'text';
- iconHidden.classList.add('d-none');
- iconVisible.classList.remove('d-none');
+ this.classList.add('is-password-visible');
this.setAttribute('aria-label', '{{ hidden_label|default('Hide password') }}');
} else {
input.type = 'password';
- iconHidden.classList.remove('d-none');
- iconVisible.classList.add('d-none');
+ this.classList.remove('is-password-visible');
this.setAttribute('aria-label', '{{ visible_label|default('Show password') }}');
}
">
-
+
{%- endblock toggle_password_widget -%}
-
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 9207a69..25d60c9 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+
+- **CSP-safe icon visibility:** hide/show eye icons via `is-password-visible` + `toggle_password.css` instead of inline `style="display: none"` / `element.style.display`. When `style-src` includes a nonce, browsers ignore `'unsafe-inline'`, so both icons were visible under strict CSP (e.g. Beacon). Demos aligned to the same class toggle (no Bootstrap `d-none`).
+
## [2.1.0] - 2026-08-04
### Added
diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md
index ea2975b..bb3494c 100644
--- a/docs/UPGRADING.md
+++ b/docs/UPGRADING.md
@@ -2,6 +2,12 @@
This document describes how to upgrade between major versions of Password Toggle Bundle.
+## Unreleased (CSP-safe icon visibility)
+
+- **Load the bundle CSS** (`asset('css/toggle_password.css', 'nowo_password_toggle')` or import the SCSS). Icon show/hide now depends on `.icon-visible` / `.is-password-visible` rules in that stylesheet.
+- **Custom form theme overrides:** if you copied `toggle_password_widget.html.twig`, stop using inline `style.display` / `style: 'display: none'` / Bootstrap `d-none`. Toggle `is-password-visible` on the button (see the bundle widget) and keep both icons in the markup without inline hide styles.
+- **Hosts with strict `script-src` (no `'unsafe-inline'`):** the default widget still uses inline `onclick` for Live Component compatibility; override the form theme with a Stimulus (or other non-inline) controller if needed — same as before.
+
## 2.0.5
- **No Twig / `PasswordType` config changes** for normal use.
diff --git a/src/Resources/public/css/toggle_password.css b/src/Resources/public/css/toggle_password.css
index d2df355..b7a663f 100644
--- a/src/Resources/public/css/toggle_password.css
+++ b/src/Resources/public/css/toggle_password.css
@@ -48,3 +48,22 @@
transition: opacity 0.2s ease-in-out;
}
+/*
+ * One eye at a time via stylesheet (CSP-safe).
+ * style-src with a nonce ignores 'unsafe-inline', so style="display:none"
+ * / element.style.display would leave both icons visible.
+ */
+.form-password-toggle .icon-visible,
+.input-group-text.cursor-pointer .icon-visible {
+ display: none;
+}
+
+.form-password-toggle .is-password-visible .icon-hidden,
+.input-group-text.cursor-pointer.is-password-visible .icon-hidden {
+ display: none;
+}
+
+.form-password-toggle .is-password-visible .icon-visible,
+.input-group-text.cursor-pointer.is-password-visible .icon-visible {
+ display: block;
+}
diff --git a/src/Resources/public/css/toggle_password.scss b/src/Resources/public/css/toggle_password.scss
index db265d9..cea2f4e 100644
--- a/src/Resources/public/css/toggle_password.scss
+++ b/src/Resources/public/css/toggle_password.scss
@@ -5,13 +5,13 @@
* place the toggle button next to the input
*/
-// Contenedor con input-group
+// Container with input-group
.input-group.input-group-merge {
- // Ya tiene los estilos de Bootstrap
- // Solo añadimos estilos específicos si es necesario
+ // Already has Bootstrap styles
+ // Only add specific styles if necessary
}
-// Botón de toggle (span.input-group-text)
+// Toggle button (span.input-group-text)
.input-group-text.cursor-pointer {
cursor: pointer;
user-select: none;
@@ -27,13 +27,13 @@
transform: scale(0.95);
}
- // Focus para accesibilidad
+ // Focus for accessibility
&:focus-visible {
outline: 2px solid var(--bs-primary, #696cff);
outline-offset: 2px;
}
- // Icono dentro del botón
+ // Icon inside the button
svg {
width: 1.25rem;
height: 1.25rem;
@@ -46,5 +46,29 @@
height: 1.25rem;
transition: opacity 0.2s ease-in-out;
}
+
+ // One eye at a time (CSP-safe; see toggle_password.css)
+ .icon-visible {
+ display: none;
+ }
+
+ &.is-password-visible .icon-hidden {
+ display: none;
+ }
+
+ &.is-password-visible .icon-visible {
+ display: block;
+ }
}
+.form-password-toggle .icon-visible {
+ display: none;
+}
+
+.form-password-toggle .is-password-visible .icon-hidden {
+ display: none;
+}
+
+.form-password-toggle .is-password-visible .icon-visible {
+ display: block;
+}
diff --git a/src/Resources/views/Form/toggle_password_widget.html.twig b/src/Resources/views/Form/toggle_password_widget.html.twig
index 45f125a..8e53e18 100644
--- a/src/Resources/views/Form/toggle_password_widget.html.twig
+++ b/src/Resources/views/Form/toggle_password_widget.html.twig
@@ -14,6 +14,10 @@
NATIVE TOGGLE: Uses inline onclick to avoid conflicts with LiveComponent
Does NOT use Stimulus - Pure JavaScript for maximum compatibility
+
+ Icon visibility uses class {@code is-password-visible} + toggle_password.css
+ (not inline style attributes or JS style mutation). CSP style-src with a nonce
+ ignores 'unsafe-inline', so an inline display:none would show both eye icons.
#}
{%- set toggle_enabled = toggle ?? true -%}
@@ -29,18 +33,14 @@
aria-label="{{ visible_label|default('Show password') }}"
onclick="
const input = this.previousElementSibling;
- const iconHidden = this.querySelector('.icon-hidden');
- const iconVisible = this.querySelector('.icon-visible');
if (input && input.type === 'password') {
input.type = 'text';
- if (iconHidden) iconHidden.style.display = 'none';
- if (iconVisible) iconVisible.style.display = '';
+ this.classList.add('is-password-visible');
this.setAttribute('aria-label', '{{ hidden_label|default('Hide password') }}');
} else if (input) {
input.type = 'password';
- if (iconHidden) iconHidden.style.display = '';
- if (iconVisible) iconVisible.style.display = 'none';
+ this.classList.remove('is-password-visible');
this.setAttribute('aria-label', '{{ visible_label|default('Show password') }}');
}
"
@@ -55,7 +55,7 @@
{{ ux_icon(visible_icon|default('tabler:eye-off'), {class: 'icon-hidden icon-base ti tabler'}) }}
{%- endif -%}
{%- if hidden_icon is defined and hidden_icon -%}
- {{ ux_icon(hidden_icon|default('tabler:eye'), {class: 'icon-visible icon-base ti tabler', style: 'display: none'}) }}
+ {{ ux_icon(hidden_icon|default('tabler:eye'), {class: 'icon-visible icon-base ti tabler'}) }}
{%- endif -%}
{%- elseif app.environment is defined and app.environment == 'dev' -%}
[icons missing]
diff --git a/tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php b/tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php
index dfdd259..1e6cd04 100644
--- a/tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php
+++ b/tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php
@@ -19,8 +19,9 @@ public function testTemplateContainsGracefulIconFallback(): void
$this->assertStringContainsString('icons_available', $content);
$this->assertStringNotContainsString('ux_icon is defined', $content);
$this->assertStringNotContainsString('d-none', $content);
- $this->assertStringContainsString('iconHidden.style.display', $content);
- $this->assertStringContainsString("style: 'display: none'", $content);
+ $this->assertStringNotContainsString('style.display', $content);
+ $this->assertStringNotContainsString("style: 'display: none'", $content);
+ $this->assertStringContainsString('is-password-visible', $content);
$this->assertStringContainsString('password-toggle-icon-missing', $content);
}
}