Skip to content

Commit 1a8ea0c

Browse files
committed
feature #1000 new TogglePassword component (feymo)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- new TogglePassword component | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | N/A | License | MIT Hello to the Symfony UX community ! This PR propose a new Symfony UX Component. The `TogglePassword` component allows users to switch visibility on password inputs through a dedicated button. Default style is provided and english language is used for labels but everything is customizable to fit projects requirements. Here are screenshots previews for the default rendering : <img width="312" alt="image" src="https://github.com/symfony/ux/assets/60115888/749c1614-407f-48c5-85ac-2a99fa63663d"> <img width="312" alt="image" src="https://github.com/symfony/ux/assets/60115888/9cf6af11-5943-44b7-bc36-3c0cbd811b4e"> Thanks you all in advance for your feedbacks and comments on the relevance of a component like this within Symfony UX ! 😄 _Edit:_ The TogglePassword component propose : - A `FormExtension` transforming any `PasswordType` field into a Toggle Password by adding the `toggle` option ; - A form theme with complete style, English labels and SVG icons for "toggable" password field "out of the box" ; - Translation support for both toggle labels (with `TranslatableMessage` object or translation key string with domain) and the option to disable translation ; - Customization for : labels, icons, classes for the toggle button element, and classes for the container element. Commits ------- b1bf8f0 new TogglePassword component
2 parents 3c13a1b + b1bf8f0 commit 1a8ea0c

25 files changed

+1093
-0
lines changed

src/TogglePassword/.gitattributes

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/.gitattributes export-ignore
2+
/.gitignore export-ignore
3+
/.symfony.bundle.yaml export-ignore
4+
/phpunit.xml.dist export-ignore
5+
/assets/src export-ignore
6+
/assets/test export-ignore
7+
/assets/jest.config.js export-ignore
8+
/tests export-ignore

src/TogglePassword/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
vendor/
2+
composer.lock
3+
.phpunit.result.cache
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
branches: ["2.x"]
2+
maintained_branches: ["2.x"]
3+
doc_dir: "doc"

src/TogglePassword/LICENSE

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Copyright (c) 2023-present Fabien Potencier
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a copy
4+
of this software and associated documentation files (the "Software"), to deal
5+
in the Software without restriction, including without limitation the rights
6+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
copies of the Software, and to permit persons to whom the Software is furnished
8+
to do so, subject to the following conditions:
9+
10+
The above copyright notice and this permission notice shall be included in all
11+
copies or substantial portions of the Software.
12+
13+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19+
THE SOFTWARE.

src/TogglePassword/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Symfony UX TogglePassword
2+
3+
Symfony UX TogglePassword is a Symfony bundle providing visibility toggle for password inputs
4+
in Symfony Forms. It is part of [the Symfony UX initiative](https://symfony.com/ux).
5+
6+
It allows visitors to switch the type of password field to text and vice versa.
7+
8+
**This repository is a READ-ONLY sub-tree split**. See
9+
https://github.com/symfony/ux to create issues or submit pull requests.
10+
11+
## Sponsor
12+
13+
The Symfony UX packages are [backed][1] by [Mercure.rocks][2].
14+
15+
Create real-time experiences in minutes! Mercure.rocks provides a realtime API service
16+
that is tightly integrated with Symfony: create UIs that update in live with UX Turbo,
17+
send notifications with the Notifier component, expose async APIs with API Platform and
18+
create low level stuffs with the Mercure component. We maintain and scale the complex
19+
infrastructure for you!
20+
21+
Help Symfony by [sponsoring][3] its development!
22+
23+
## Resources
24+
25+
- [Documentation](https://symfony.com/bundles/ux-toggle-password/current/index.html)
26+
- [Report issues](https://github.com/symfony/ux/issues) and
27+
[send Pull Requests](https://github.com/symfony/ux/pulls)
28+
in the [main Symfony UX repository](https://github.com/symfony/ux)
29+
30+
[1]: https://symfony.com/backers
31+
[2]: https://mercure.rocks
32+
[3]: https://symfony.com/sponsor
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
export default class extends Controller<HTMLInputElement> {
3+
readonly visibleLabelValue: string;
4+
readonly visibleIconValue: string;
5+
readonly hiddenLabelValue: string;
6+
readonly hiddenIconValue: string;
7+
readonly buttonClassesValue: Array<string>;
8+
static values: {
9+
visibleLabel: StringConstructor;
10+
visibleIcon: StringConstructor;
11+
hiddenLabel: StringConstructor;
12+
hiddenIcon: StringConstructor;
13+
buttonClasses: ArrayConstructor;
14+
};
15+
isDisplayed: boolean;
16+
visibleIcon: string;
17+
hiddenIcon: string;
18+
connect(): void;
19+
private createButton;
20+
toggle(event: any): void;
21+
private dispatchEvent;
22+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
3+
class default_1 extends Controller {
4+
constructor() {
5+
super(...arguments);
6+
this.isDisplayed = false;
7+
this.visibleIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="toggle-password-icon" viewBox="0 0 20 20" fill="currentColor">
8+
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
9+
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd" />
10+
</svg>`;
11+
this.hiddenIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="toggle-password-icon" viewBox="0 0 20 20" fill="currentColor">
12+
<path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clip-rule="evenodd" />
13+
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
14+
</svg>`;
15+
}
16+
connect() {
17+
if (this.visibleIconValue !== 'Default') {
18+
this.visibleIcon = this.visibleIconValue;
19+
}
20+
if (this.hiddenIconValue !== 'Default') {
21+
this.hiddenIcon = this.hiddenIconValue;
22+
}
23+
const button = this.createButton();
24+
this.element.insertAdjacentElement('afterend', button);
25+
this.dispatchEvent('connect', { element: this.element, button: button });
26+
}
27+
createButton() {
28+
const button = document.createElement('button');
29+
button.type = 'button';
30+
button.classList.add(...this.buttonClassesValue);
31+
button.setAttribute('tabindex', '-1');
32+
button.addEventListener('click', this.toggle.bind(this));
33+
button.innerHTML = this.visibleIcon + ' ' + this.visibleLabelValue;
34+
return button;
35+
}
36+
toggle(event) {
37+
this.isDisplayed = !this.isDisplayed;
38+
const toggleButtonElement = event.currentTarget;
39+
toggleButtonElement.innerHTML = this.isDisplayed
40+
? this.hiddenIcon + ' ' + this.hiddenLabelValue
41+
: this.visibleIcon + ' ' + this.visibleLabelValue;
42+
this.element.setAttribute('type', this.isDisplayed ? 'text' : 'password');
43+
this.dispatchEvent(this.isDisplayed ? 'show' : 'hide', { element: this.element, button: toggleButtonElement });
44+
}
45+
dispatchEvent(name, payload) {
46+
this.dispatch(name, { detail: payload, prefix: 'toggle-password' });
47+
}
48+
}
49+
default_1.values = {
50+
visibleLabel: String,
51+
visibleIcon: String,
52+
hiddenLabel: String,
53+
hiddenIcon: String,
54+
buttonClasses: Array,
55+
};
56+
57+
export { default_1 as default };

src/TogglePassword/assets/dist/style.min.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('../../../jest.config.js');
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "@symfony/ux-toggle-password",
3+
"description": "Toggle visibility of password inputs for Symfony Forms",
4+
"license": "MIT",
5+
"version": "1.0.0",
6+
"main": "dist/controller.js",
7+
"types": "dist/controller.d.ts",
8+
"config": {
9+
"css_source": "src/style.css"
10+
},
11+
"symfony": {
12+
"controllers": {
13+
"toggle-password": {
14+
"main": "dist/controller.js",
15+
"fetch": "eager",
16+
"enabled": true,
17+
"autoimport": {
18+
"@symfony/ux-toggle-password/dist/style.min.css": true
19+
}
20+
}
21+
},
22+
"importmap": {
23+
"@hotwired/stimulus": "^3.0.0"
24+
}
25+
},
26+
"peerDependencies": {
27+
"@hotwired/stimulus": "^3.0.0"
28+
},
29+
"devDependencies": {
30+
"@hotwired/stimulus": "^3.0.0"
31+
}
32+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
'use strict';
11+
12+
import { Controller } from '@hotwired/stimulus';
13+
14+
export default class extends Controller<HTMLInputElement> {
15+
declare readonly visibleLabelValue: string;
16+
declare readonly visibleIconValue: string;
17+
declare readonly hiddenLabelValue: string;
18+
declare readonly hiddenIconValue: string;
19+
declare readonly buttonClassesValue: Array<string>;
20+
21+
static values = {
22+
visibleLabel: String,
23+
visibleIcon: String,
24+
hiddenLabel: String,
25+
hiddenIcon: String,
26+
buttonClasses: Array,
27+
};
28+
29+
isDisplayed = false;
30+
visibleIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="toggle-password-icon" viewBox="0 0 20 20" fill="currentColor">
31+
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
32+
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd" />
33+
</svg>`;
34+
hiddenIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="toggle-password-icon" viewBox="0 0 20 20" fill="currentColor">
35+
<path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clip-rule="evenodd" />
36+
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
37+
</svg>`;
38+
39+
connect() {
40+
if (this.visibleIconValue !== 'Default') {
41+
this.visibleIcon = this.visibleIconValue;
42+
}
43+
if (this.hiddenIconValue !== 'Default') {
44+
this.hiddenIcon = this.hiddenIconValue;
45+
}
46+
const button = this.createButton();
47+
this.element.insertAdjacentElement('afterend', button);
48+
this.dispatchEvent('connect', { element: this.element, button: button });
49+
}
50+
51+
/**
52+
* @returns {HTMLButtonElement}
53+
*/
54+
private createButton(): HTMLButtonElement {
55+
const button: HTMLButtonElement = document.createElement('button');
56+
button.type = 'button';
57+
button.classList.add(...this.buttonClassesValue);
58+
button.setAttribute('tabindex', '-1');
59+
button.addEventListener('click', this.toggle.bind(this));
60+
button.innerHTML = this.visibleIcon + ' ' + this.visibleLabelValue;
61+
62+
return button;
63+
}
64+
65+
/**
66+
* Toggle input type between "text" or "password" and update label accordingly
67+
*/
68+
toggle(event: any): void {
69+
this.isDisplayed = !this.isDisplayed;
70+
const toggleButtonElement: HTMLButtonElement = event.currentTarget;
71+
toggleButtonElement.innerHTML = this.isDisplayed
72+
? this.hiddenIcon + ' ' + this.hiddenLabelValue
73+
: this.visibleIcon + ' ' + this.visibleLabelValue;
74+
this.element.setAttribute('type', this.isDisplayed ? 'text' : 'password');
75+
this.dispatchEvent(this.isDisplayed ? 'show' : 'hide', { element: this.element, button: toggleButtonElement });
76+
}
77+
78+
private dispatchEvent(name: string, payload: any): void {
79+
this.dispatch(name, { detail: payload, prefix: 'toggle-password' });
80+
}
81+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
.toggle-password-container {
2+
position: relative;
3+
}
4+
.toggle-password-icon {
5+
height: 1rem;
6+
width: 1rem;
7+
}
8+
.toggle-password-button {
9+
align-items: center;
10+
background-color: transparent;
11+
border: none;
12+
column-gap: 0.25rem;
13+
display: flex;
14+
flex-direction: row;
15+
font-size: 0.875rem;
16+
justify-items: center;
17+
height: 1rem;
18+
line-height: 1.25rem;
19+
position: absolute;
20+
right: 0.5rem;
21+
top: -1.25rem;
22+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
'use strict';
11+
12+
import { Application, Controller } from '@hotwired/stimulus';
13+
import { getByTestId, waitFor, getByText } from '@testing-library/dom';
14+
import user from '@testing-library/user-event';
15+
import { clearDOM, mountDOM } from '@symfony/stimulus-testing';
16+
import TogglePasswordController from '../src/controller';
17+
18+
// Controller used to check the actual controller was properly booted
19+
class CheckController extends Controller {
20+
connect() {
21+
this.element.addEventListener('toggle-password:connect', () => {
22+
this.element.classList.add('connected');
23+
});
24+
}
25+
}
26+
27+
const startStimulus = () => {
28+
const application = Application.start();
29+
application.register('check', CheckController);
30+
application.register('toggle-password', TogglePasswordController);
31+
}
32+
33+
describe('TogglePasswordController', () => {
34+
let container;
35+
36+
beforeEach(() => {
37+
container = mountDOM(`
38+
<div class="toggle-password-container">
39+
<input type="password"
40+
data-testid="input"
41+
data-controller="check toggle-password"
42+
data-toggle-password-hidden-label-value="Hide"
43+
data-toggle-password-visible-label-value="Show" />
44+
</div>
45+
`);
46+
startStimulus();
47+
});
48+
49+
afterEach(() => {
50+
clearDOM();
51+
});
52+
53+
it('should toggle the input type', async () => {
54+
const input = getByTestId(container, 'input');
55+
const button = getByText(container, 'Show');
56+
57+
expect(input.type).toBe('password');
58+
59+
user.click(button);
60+
61+
await waitFor(() => {
62+
expect(input.type).toBe('text');
63+
});
64+
65+
user.click(button);
66+
67+
await waitFor(() => {
68+
expect(input.type).toBe('password');
69+
});
70+
});
71+
});

0 commit comments

Comments
 (0)