Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** `<nowo-password-toggle>` — 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,34 @@
Icon visibility uses is-password-visible + toggle_password.css (CSP-safe).
#}

<div class="input-group input-group-merge form-password-toggle {{ (toggle_container_classes ?? [])|join(' ') }}">
{%- 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') -%}
<nowo-password-toggle
class="input-group input-group-merge form-password-toggle {{ (toggle_container_classes ?? [])|join(' ') }}"
data-nowo-password-toggle
data-nowo-password-toggle-visible-label="{{ _visible_label|e('html_attr') }}"
data-nowo-password-toggle-hidden-label="{{ _hidden_label|e('html_attr') }}"
>
<input type="{{ type }}" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %}/>
<span class="input-group-text cursor-pointer"
role="button"
tabindex="0"
aria-label="{{ visible_label|default('Show password') }}"
onclick="
const input = this.previousElementSibling;

if (input.type === 'password') {
input.type = 'text';
this.classList.add('is-password-visible');
this.setAttribute('aria-label', '{{ hidden_label|default('Hide password') }}');
} else {
input.type = 'password';
this.classList.remove('is-password-visible');
this.setAttribute('aria-label', '{{ visible_label|default('Show password') }}');
}
">
data-nowo-password-toggle-target="button"
aria-label="{{ _visible_label }}">
<i class="ti ti-eye-off icon-hidden icon-base"></i>
<i class="ti ti-eye icon-visible icon-base"></i>
</span>
</div>
</nowo-password-toggle>
{%- 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 -%}
<script src="{{ asset('js/nowo-password-toggle.js', 'nowo_password_toggle') }}" defer></script>
{%- endif -%}
{%- endblock toggle_password_widget -%}
40 changes: 23 additions & 17 deletions demo/symfony8/templates/form/toggle_password_widget.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,34 @@
Icon visibility uses is-password-visible + toggle_password.css (CSP-safe).
#}

<div class="input-group input-group-merge form-password-toggle {{ (toggle_container_classes ?? [])|join(' ') }}">
{%- 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') -%}
<nowo-password-toggle
class="input-group input-group-merge form-password-toggle {{ (toggle_container_classes ?? [])|join(' ') }}"
data-nowo-password-toggle
data-nowo-password-toggle-visible-label="{{ _visible_label|e('html_attr') }}"
data-nowo-password-toggle-hidden-label="{{ _hidden_label|e('html_attr') }}"
>
<input type="{{ type }}" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %}/>
<span class="input-group-text cursor-pointer"
role="button"
tabindex="0"
aria-label="{{ visible_label|default('Show password') }}"
onclick="
const input = this.previousElementSibling;

if (input.type === 'password') {
input.type = 'text';
this.classList.add('is-password-visible');
this.setAttribute('aria-label', '{{ hidden_label|default('Hide password') }}');
} else {
input.type = 'password';
this.classList.remove('is-password-visible');
this.setAttribute('aria-label', '{{ visible_label|default('Show password') }}');
}
">
data-nowo-password-toggle-target="button"
aria-label="{{ _visible_label }}">
<i class="ti ti-eye-off icon-hidden icon-base"></i>
<i class="ti ti-eye icon-visible icon-base"></i>
</span>
</div>
</nowo-password-toggle>
{%- 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 -%}
<script src="{{ asset('js/nowo-password-toggle.js', 'nowo_password_toggle') }}" defer></script>
{%- endif -%}
{%- endblock toggle_password_widget -%}
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<nowo-password-toggle>` 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

Expand Down
2 changes: 2 additions & 0 deletions docs/INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ return [
<link rel="stylesheet" href="{{ asset('css/toggle_password.css', 'nowo_password_toggle') }}">
```

The default widget also loads `js/nowo-password-toggle.js` (custom element `<nowo-password-toggle>`). 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/`.
Expand Down
5 changes: 5 additions & 0 deletions docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<nowo-password-toggle>` 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 `<nowo-password-toggle>` (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.**
Expand Down
7 changes: 7 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ With **UX Icons 3.x**, this command scans Twig templates for `ux_icon()` usage (

- **Option 1:** Include the bundle CSS:
`<link rel="stylesheet" href="{{ asset('css/toggle_password.css', 'nowo_password_toggle') }}">`
- **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
<script src="{{ asset('js/nowo-password-toggle.js', 'nowo_password_toggle') }}" defer></script>
```

The host tag is `<nowo-password-toggle>` (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.
Expand Down
2 changes: 1 addition & 1 deletion src/Form/Type/PasswordType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<nowo-password-toggle>`) 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.
Expand Down
6 changes: 6 additions & 0 deletions src/Resources/public/css/toggle_password.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
6 changes: 6 additions & 0 deletions src/Resources/public/css/toggle_password.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
146 changes: 146 additions & 0 deletions src/Resources/public/js/nowo-password-toggle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* CSP-safe password visibility toggle for nowo-tech/password-toggle-bundle.
*
* Defines <nowo-password-toggle> 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);
Loading
Loading