From c73f963b697578521efc84d33fb743ef0682e193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Franco?= Date: Mon, 24 Aug 2026 19:32:10 +0200 Subject: [PATCH] feat: replace inline toggle handlers with nowo-password-toggle Drop onclick/onkeydown so the widget is CSP-safe and still works with Live Components via event delegation. --- README.md | 2 +- .../form/toggle_password_widget.html.twig | 40 +++-- .../form/toggle_password_widget.html.twig | 40 +++-- docs/CHANGELOG.md | 4 + docs/INSTALLATION.md | 2 + docs/UPGRADING.md | 5 + docs/USAGE.md | 7 + src/Form/Type/PasswordType.php | 2 +- src/Resources/public/css/toggle_password.css | 6 + src/Resources/public/css/toggle_password.scss | 6 + .../public/js/nowo-password-toggle.js | 146 ++++++++++++++++++ .../Form/toggle_password_widget.html.twig | 50 +++--- .../Twig/TogglePasswordWidgetTemplateTest.php | 16 ++ 13 files changed, 265 insertions(+), 61 deletions(-) create mode 100644 src/Resources/public/js/nowo-password-toggle.js 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); + } }