diff --git a/README.md b/README.md
index 833b3aa..d3a23ba 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ This bundle is **FrankenPHP worker mode friendly**.
- ✅ Password form type with toggle visibility
- ✅ Customizable icons and labels
-- ✅ **No Stimulus / no extra asset bundle** — toggle uses **inline** `onclick` / `onkeydown` (see `toggle_password_widget.html.twig`) for compatibility with Live Components
+- ✅ **Web Component** `` — CSP-safe script (no inline `onclick`); event delegation stays compatible with Live Components
- ✅ Icons via **`symfony/ux-icons`** + **`symfony/http-client`** (Flex recipe installs both; graceful fallback + log warning if missing)
- ✅ Fully configurable CSS classes
- ✅ Works with Live Components
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 d250868..8c26a7d 100644
--- a/demo/symfony8-php85/templates/form/toggle_password_widget.html.twig
+++ b/demo/symfony8-php85/templates/form/toggle_password_widget.html.twig
@@ -5,28 +5,34 @@
Icon visibility uses is-password-visible + toggle_password.css (CSP-safe).
#}
-
- {%- set type = type|default('password') -%}
+ {%- set type = type|default('password') -%}
+ {%- set _visible_label = visible_label|default('Show password') -%}
+ {%- set _hidden_label = hidden_label|default('Hide password') -%}
+
+ data-nowo-password-toggle-target="button"
+ aria-label="{{ _visible_label }}">
-
+
+ {%- set _load_toggle_js = true -%}
+ {%- if app is defined and app.request is defined -%}
+ {%- if app.request.attributes.get('_nowo_password_toggle_js') -%}
+ {%- set _load_toggle_js = false -%}
+ {%- else -%}
+ {%- set _ = app.request.attributes.set('_nowo_password_toggle_js', true) -%}
+ {%- endif -%}
+ {%- endif -%}
+ {%- if _load_toggle_js -%}
+
+ {%- endif -%}
{%- endblock toggle_password_widget -%}
diff --git a/demo/symfony8/templates/form/toggle_password_widget.html.twig b/demo/symfony8/templates/form/toggle_password_widget.html.twig
index d250868..8c26a7d 100644
--- a/demo/symfony8/templates/form/toggle_password_widget.html.twig
+++ b/demo/symfony8/templates/form/toggle_password_widget.html.twig
@@ -5,28 +5,34 @@
Icon visibility uses is-password-visible + toggle_password.css (CSP-safe).
#}
-
- {%- set type = type|default('password') -%}
+ {%- set type = type|default('password') -%}
+ {%- set _visible_label = visible_label|default('Show password') -%}
+ {%- set _hidden_label = hidden_label|default('Hide password') -%}
+
+ data-nowo-password-toggle-target="button"
+ aria-label="{{ _visible_label }}">
-
+
+ {%- set _load_toggle_js = true -%}
+ {%- if app is defined and app.request is defined -%}
+ {%- if app.request.attributes.get('_nowo_password_toggle_js') -%}
+ {%- set _load_toggle_js = false -%}
+ {%- else -%}
+ {%- set _ = app.request.attributes.set('_nowo_password_toggle_js', true) -%}
+ {%- endif -%}
+ {%- endif -%}
+ {%- if _load_toggle_js -%}
+
+ {%- endif -%}
{%- endblock toggle_password_widget -%}
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 6cc88fd..3ec354f 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -35,6 +35,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed
+
+- **Web Component:** the widget renders `` and loads `js/nowo-password-toggle.js` (CSP-safe; no inline `onclick` / `onkeydown`). Event delegation on the host remains compatible with Live Components.
+
## [2.1.4] - 2026-08-24
diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md
index d6a1c6e..8535706 100644
--- a/docs/INSTALLATION.md
+++ b/docs/INSTALLATION.md
@@ -90,6 +90,8 @@ return [
```
+ The default widget also loads `js/nowo-password-toggle.js` (custom element ``). Run `assets:install` so both CSS and JS are published under `public/bundles/nowopasswordtoggle/`.
+
### AssetMapper
If your app uses [Symfony AssetMapper](https://symfony.com/doc/current/frontend/asset_mapper.html), the bundle registers the `nowo_password_toggle` asset package. Run `assets:install` once so `css/toggle_password.css` is published to `public/bundles/nowopasswordtoggle/`.
diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md
index a447d26..d350702 100644
--- a/docs/UPGRADING.md
+++ b/docs/UPGRADING.md
@@ -2,8 +2,13 @@
## Table of contents
+- [Unreleased](#unreleased)
- [From 2.1.3 to 2.1.4](#from-213-to-214)
+## Unreleased
+
+The default widget is now `` plus `js/nowo-password-toggle.js` (loaded once per request). Run `php bin/console assets:install`. If you copied `toggle_password_widget.html.twig`, remove inline `onclick` / `onkeydown` and wrap the input group in `` (see the bundle template). Strict `script-src` hosts no longer need `'unsafe-inline'` for the toggle.
+
## From 2.1.3 to 2.1.4
No breaking changes. **No application upgrade steps.**
diff --git a/docs/USAGE.md b/docs/USAGE.md
index b1edb9c..c14468e 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -73,6 +73,13 @@ With **UX Icons 3.x**, this command scans Twig templates for `ux_icon()` usage (
- **Option 1:** Include the bundle CSS:
``
+- **Web Component script:** the default widget loads `js/nowo-password-toggle.js` once per request. After `assets:install` you can also include it in the layout:
+
+```twig
+
+```
+
+The host tag is `` (light DOM: native password input + toggle button). Inline `onclick` / `onkeydown` handlers are no longer used.
- **Option 2:** Import the SCSS in your build (Webpack Encore, Vite, etc.):
`@import '@nowo-tech/password-toggle-bundle/src/Resources/public/css/toggle_password.scss';`
- **Option 3:** Style the classes yourself: `.input-group-text.cursor-pointer`, `.form-password-toggle`, etc.
diff --git a/src/Form/Type/PasswordType.php b/src/Form/Type/PasswordType.php
index 069a502..ca128b2 100644
--- a/src/Form/Type/PasswordType.php
+++ b/src/Form/Type/PasswordType.php
@@ -22,7 +22,7 @@
* - Toggle button with customizable icons (hidden/visible states)
* - Customizable labels for accessibility
* - Configurable CSS classes for styling
- * - Native JavaScript implementation for maximum compatibility
+ * - Native JavaScript custom element (``) for CSP-safe toggling
*
* Default values can be configured in config/packages/nowo_password_toggle.yaml
* and can be overridden when using this form type in a form builder.
diff --git a/src/Resources/public/css/toggle_password.css b/src/Resources/public/css/toggle_password.css
index b7a663f..08d308c 100644
--- a/src/Resources/public/css/toggle_password.css
+++ b/src/Resources/public/css/toggle_password.css
@@ -5,6 +5,12 @@
* place the toggle button next to the input
*/
+nowo-password-toggle {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+}
+
/* Container with input-group */
.input-group.input-group-merge {
/* Already has Bootstrap styles */
diff --git a/src/Resources/public/css/toggle_password.scss b/src/Resources/public/css/toggle_password.scss
index cea2f4e..367158d 100644
--- a/src/Resources/public/css/toggle_password.scss
+++ b/src/Resources/public/css/toggle_password.scss
@@ -5,6 +5,12 @@
* place the toggle button next to the input
*/
+nowo-password-toggle {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+}
+
// Container with input-group
.input-group.input-group-merge {
// Already has Bootstrap styles
diff --git a/src/Resources/public/js/nowo-password-toggle.js b/src/Resources/public/js/nowo-password-toggle.js
new file mode 100644
index 0000000..033c5e1
--- /dev/null
+++ b/src/Resources/public/js/nowo-password-toggle.js
@@ -0,0 +1,146 @@
+/**
+ * CSP-safe password visibility toggle for nowo-tech/password-toggle-bundle.
+ *
+ * Defines and enhances legacy .form-password-toggle hosts.
+ * Uses event delegation on the host so Live Component morphs keep working
+ * without inline onclick / onkeydown handlers.
+ */
+(function (global) {
+ 'use strict';
+
+ var TAG = 'nowo-password-toggle';
+ var ATTR_INIT = 'data-nowo-password-toggle-init';
+ var HOST_SELECTOR = TAG + ', [data-nowo-password-toggle], .form-password-toggle';
+
+ /**
+ * @param {string|null|undefined} value
+ * @param {string} fallback
+ * @returns {string}
+ */
+ function attrOr(value, fallback) {
+ if (value === undefined || value === null || value === '') {
+ return fallback;
+ }
+ return value;
+ }
+
+ /**
+ * @param {HTMLElement} host
+ * @returns {HTMLInputElement|null}
+ */
+ function findInput(host) {
+ return host.querySelector('input');
+ }
+
+ /**
+ * @param {EventTarget|null} target
+ * @param {HTMLElement} host
+ * @returns {HTMLElement|null}
+ */
+ function findToggleButton(target, host) {
+ if (!(target instanceof Element)) {
+ return null;
+ }
+ var button = target.closest('[data-nowo-password-toggle-target="button"], [role="button"]');
+ if (!(button instanceof HTMLElement) || !host.contains(button)) {
+ return null;
+ }
+ return button;
+ }
+
+ /**
+ * @param {HTMLElement} host
+ * @param {HTMLInputElement} input
+ * @param {HTMLElement} button
+ */
+ function toggleVisibility(host, input, button) {
+ var visibleLabel = attrOr(
+ host.getAttribute('data-nowo-password-toggle-visible-label'),
+ button.getAttribute('data-visible-label') || 'Show password'
+ );
+ var hiddenLabel = attrOr(
+ host.getAttribute('data-nowo-password-toggle-hidden-label'),
+ button.getAttribute('data-hidden-label') || 'Hide password'
+ );
+
+ if (input.type === 'password') {
+ input.type = 'text';
+ button.classList.add('is-password-visible');
+ button.setAttribute('aria-label', hiddenLabel);
+ } else {
+ input.type = 'password';
+ button.classList.remove('is-password-visible');
+ button.setAttribute('aria-label', visibleLabel);
+ }
+ }
+
+ /**
+ * @param {HTMLElement} host
+ */
+ function enhanceHost(host) {
+ if (host.getAttribute(ATTR_INIT) === '1') {
+ return;
+ }
+ host.setAttribute(ATTR_INIT, '1');
+
+ host.addEventListener('click', function (event) {
+ var input = findInput(host);
+ var button = findToggleButton(event.target, host);
+ if (!input || !button) {
+ return;
+ }
+ toggleVisibility(host, input, button);
+ });
+
+ host.addEventListener('keydown', function (event) {
+ if (event.key !== 'Enter' && event.key !== ' ') {
+ return;
+ }
+ var input = findInput(host);
+ var button = findToggleButton(event.target, host);
+ if (!input || !button) {
+ return;
+ }
+ event.preventDefault();
+ toggleVisibility(host, input, button);
+ });
+ }
+
+ /**
+ * @param {ParentNode} [scope]
+ */
+ function enhanceAll(scope) {
+ var root = scope || document;
+ var nodes = root.querySelectorAll(HOST_SELECTOR);
+ Array.prototype.forEach.call(nodes, function (node) {
+ if (node instanceof HTMLElement) {
+ enhanceHost(node);
+ }
+ });
+ }
+
+ class NowoPasswordToggleElement extends HTMLElement {
+ connectedCallback() {
+ enhanceHost(this);
+ }
+ }
+
+ if (typeof customElements !== 'undefined' && customElements.get(TAG) === undefined) {
+ customElements.define(TAG, NowoPasswordToggleElement);
+ }
+
+ global.NowoPasswordToggle = {
+ enhance: enhanceHost,
+ enhanceAll: enhanceAll,
+ };
+
+ function boot() {
+ enhanceAll(document);
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', boot);
+ } else {
+ boot();
+ }
+})(typeof window !== 'undefined' ? window : this);
diff --git a/src/Resources/views/Form/toggle_password_widget.html.twig b/src/Resources/views/Form/toggle_password_widget.html.twig
index 8e53e18..ac3a320 100644
--- a/src/Resources/views/Form/toggle_password_widget.html.twig
+++ b/src/Resources/views/Form/toggle_password_widget.html.twig
@@ -1,6 +1,6 @@
{%- block toggle_password_widget -%}
{#
- Template for password field with visibility toggle
+ Template for password field with visibility toggle.
Available variables:
- toggle: Enable/disable toggle functionality (default: true)
@@ -12,8 +12,8 @@
- button_classes: CSS classes for the toggle button
- icons_available: Whether symfony/ux-icons and symfony/http-client are available
- NATIVE TOGGLE: Uses inline onclick to avoid conflicts with LiveComponent
- Does NOT use Stimulus - Pure JavaScript for maximum compatibility
+ WEB COMPONENT: `` (light DOM) + `nowo-password-toggle.js`.
+ Event delegation on the host keeps LiveComponent morphs working without inline handlers.
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
@@ -23,33 +23,22 @@
{%- set toggle_enabled = toggle ?? true -%}
{%- set type = type|default('password') -%}
{%- set icons_available = icons_available|default(false) -%}
+ {%- set _visible_label = visible_label|default('Show password') -%}
+ {%- set _hidden_label = hidden_label|default('Hide password') -%}
{%- if toggle_enabled -%}
-
+
+ data-nowo-password-toggle-target="button"
+ aria-label="{{ _visible_label }}">
{%- if icons_available -%}
{%- if visible_icon is defined and visible_icon -%}
{{ ux_icon(visible_icon|default('tabler:eye-off'), {class: 'icon-hidden icon-base ti tabler'}) }}
@@ -61,7 +50,18 @@
[icons missing]
{%- endif -%}
-
+
+ {%- set _load_toggle_js = true -%}
+ {%- if app is defined and app.request is defined -%}
+ {%- if app.request.attributes.get('_nowo_password_toggle_js') -%}
+ {%- set _load_toggle_js = false -%}
+ {%- else -%}
+ {%- set _ = app.request.attributes.set('_nowo_password_toggle_js', true) -%}
+ {%- endif -%}
+ {%- endif -%}
+ {%- if _load_toggle_js -%}
+
+ {%- endif -%}
{%- else -%}
{# Render simple password input when toggle is disabled #}
diff --git a/tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php b/tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php
index 1e6cd04..08dcf6a 100644
--- a/tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php
+++ b/tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php
@@ -22,6 +22,22 @@ public function testTemplateContainsGracefulIconFallback(): void
$this->assertStringNotContainsString('style.display', $content);
$this->assertStringNotContainsString("style: 'display: none'", $content);
$this->assertStringContainsString('is-password-visible', $content);
+ $this->assertStringContainsString('nowo-password-toggle', $content);
+ $this->assertStringContainsString("asset('js/nowo-password-toggle.js', 'nowo_password_toggle')", $content);
+ $this->assertStringNotContainsString('onclick=', $content);
+ $this->assertStringNotContainsString('onkeydown=', $content);
$this->assertStringContainsString('password-toggle-icon-missing', $content);
}
+
+ public function testToggleScriptAssetDefinesCustomElement(): void
+ {
+ $path = dirname(__DIR__, 3) . '/src/Resources/public/js/nowo-password-toggle.js';
+ $content = file_get_contents($path);
+
+ $this->assertIsString($content);
+ $this->assertStringContainsString('customElements.define(TAG, NowoPasswordToggleElement)', $content);
+ $this->assertStringContainsString('nowo-password-toggle', $content);
+ $this->assertStringContainsString('NowoPasswordToggle', $content);
+ $this->assertStringNotContainsString('onclick=', $content);
+ }
}