Skip to content

new TogglePassword component #1000

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 31, 2023
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
8 changes: 8 additions & 0 deletions src/TogglePassword/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/.gitattributes export-ignore
/.gitignore export-ignore
/.symfony.bundle.yaml export-ignore
/phpunit.xml.dist export-ignore
/assets/src export-ignore
/assets/test export-ignore
/assets/jest.config.js export-ignore
/tests export-ignore
3 changes: 3 additions & 0 deletions src/TogglePassword/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
vendor/
composer.lock
.phpunit.result.cache
3 changes: 3 additions & 0 deletions src/TogglePassword/.symfony.bundle.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
branches: ["2.x"]
maintained_branches: ["2.x"]
doc_dir: "doc"
19 changes: 19 additions & 0 deletions src/TogglePassword/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Copyright (c) 2023-present Fabien Potencier

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
32 changes: 32 additions & 0 deletions src/TogglePassword/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Symfony UX TogglePassword

Symfony UX TogglePassword is a Symfony bundle providing visibility toggle for password inputs
in Symfony Forms. It is part of [the Symfony UX initiative](https://symfony.com/ux).

It allows visitors to switch the type of password field to text and vice versa.

**This repository is a READ-ONLY sub-tree split**. See
https://github.com/symfony/ux to create issues or submit pull requests.

## Sponsor

The Symfony UX packages are [backed][1] by [Mercure.rocks][2].

Create real-time experiences in minutes! Mercure.rocks provides a realtime API service
that is tightly integrated with Symfony: create UIs that update in live with UX Turbo,
send notifications with the Notifier component, expose async APIs with API Platform and
create low level stuffs with the Mercure component. We maintain and scale the complex
infrastructure for you!

Help Symfony by [sponsoring][3] its development!

## Resources

- [Documentation](https://symfony.com/bundles/ux-toggle-password/current/index.html)
- [Report issues](https://github.com/symfony/ux/issues) and
[send Pull Requests](https://github.com/symfony/ux/pulls)
in the [main Symfony UX repository](https://github.com/symfony/ux)

[1]: https://symfony.com/backers
[2]: https://mercure.rocks
[3]: https://symfony.com/sponsor
22 changes: 22 additions & 0 deletions src/TogglePassword/assets/dist/controller.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller<HTMLInputElement> {
readonly visibleLabelValue: string;
readonly visibleIconValue: string;
readonly hiddenLabelValue: string;
readonly hiddenIconValue: string;
readonly buttonClassesValue: Array<string>;
static values: {
visibleLabel: StringConstructor;
visibleIcon: StringConstructor;
hiddenLabel: StringConstructor;
hiddenIcon: StringConstructor;
buttonClasses: ArrayConstructor;
};
isDisplayed: boolean;
visibleIcon: string;
hiddenIcon: string;
connect(): void;
private createButton;
toggle(event: any): void;
private dispatchEvent;
}
57 changes: 57 additions & 0 deletions src/TogglePassword/assets/dist/controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Controller } from '@hotwired/stimulus';

class default_1 extends Controller {
constructor() {
super(...arguments);
this.isDisplayed = false;
this.visibleIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="toggle-password-icon" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<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" />
</svg>`;
this.hiddenIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="toggle-password-icon" viewBox="0 0 20 20" fill="currentColor">
<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" />
<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" />
</svg>`;
}
connect() {
if (this.visibleIconValue !== 'Default') {
this.visibleIcon = this.visibleIconValue;
}
if (this.hiddenIconValue !== 'Default') {
this.hiddenIcon = this.hiddenIconValue;
}
const button = this.createButton();
this.element.insertAdjacentElement('afterend', button);
this.dispatchEvent('connect', { element: this.element, button: button });
}
createButton() {
const button = document.createElement('button');
button.type = 'button';
button.classList.add(...this.buttonClassesValue);
button.setAttribute('tabindex', '-1');
button.addEventListener('click', this.toggle.bind(this));
button.innerHTML = this.visibleIcon + ' ' + this.visibleLabelValue;
return button;
}
toggle(event) {
this.isDisplayed = !this.isDisplayed;
const toggleButtonElement = event.currentTarget;
toggleButtonElement.innerHTML = this.isDisplayed
? this.hiddenIcon + ' ' + this.hiddenLabelValue
: this.visibleIcon + ' ' + this.visibleLabelValue;
this.element.setAttribute('type', this.isDisplayed ? 'text' : 'password');
this.dispatchEvent(this.isDisplayed ? 'show' : 'hide', { element: this.element, button: toggleButtonElement });
}
dispatchEvent(name, payload) {
this.dispatch(name, { detail: payload, prefix: 'toggle-password' });
}
}
default_1.values = {
visibleLabel: String,
visibleIcon: String,
hiddenLabel: String,
hiddenIcon: String,
buttonClasses: Array,
};

export { default_1 as default };
1 change: 1 addition & 0 deletions src/TogglePassword/assets/dist/style.min.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/TogglePassword/assets/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../../../jest.config.js');
32 changes: 32 additions & 0 deletions src/TogglePassword/assets/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@symfony/ux-toggle-password",
"description": "Toggle visibility of password inputs for Symfony Forms",
"license": "MIT",
"version": "1.0.0",
"main": "dist/controller.js",
"types": "dist/controller.d.ts",
"config": {
"css_source": "src/style.css"
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can remove this config key. I had never noticed that we had this on a few other, old packages. I believe it was added by accident there.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, i'll remove it 👍🏼
I thought it was needed for the css file provided with the bundle !

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It probably was in a very early WIP version of UX + Flex - that's my best guess :)

"symfony": {
"controllers": {
"toggle-password": {
"main": "dist/controller.js",
"fetch": "eager",
"enabled": true,
"autoimport": {
"@symfony/ux-toggle-password/dist/style.min.css": true
}
}
},
"importmap": {
"@hotwired/stimulus": "^3.0.0"
}
},
"peerDependencies": {
"@hotwired/stimulus": "^3.0.0"
},
"devDependencies": {
"@hotwired/stimulus": "^3.0.0"
}
}
81 changes: 81 additions & 0 deletions src/TogglePassword/assets/src/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

'use strict';

import { Controller } from '@hotwired/stimulus';

export default class extends Controller<HTMLInputElement> {
declare readonly visibleLabelValue: string;
declare readonly visibleIconValue: string;
declare readonly hiddenLabelValue: string;
declare readonly hiddenIconValue: string;
declare readonly buttonClassesValue: Array<string>;

static values = {
visibleLabel: String,
visibleIcon: String,
hiddenLabel: String,
hiddenIcon: String,
buttonClasses: Array,
};

isDisplayed = false;
visibleIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="toggle-password-icon" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<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" />
</svg>`;
hiddenIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="toggle-password-icon" viewBox="0 0 20 20" fill="currentColor">
<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" />
<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" />
</svg>`;

connect() {
if (this.visibleIconValue !== 'Default') {
this.visibleIcon = this.visibleIconValue;
}
if (this.hiddenIconValue !== 'Default') {
this.hiddenIcon = this.hiddenIconValue;
}
const button = this.createButton();
this.element.insertAdjacentElement('afterend', button);
this.dispatchEvent('connect', { element: this.element, button: button });
}

/**
* @returns {HTMLButtonElement}
*/
private createButton(): HTMLButtonElement {
const button: HTMLButtonElement = document.createElement('button');
button.type = 'button';
button.classList.add(...this.buttonClassesValue);
button.setAttribute('tabindex', '-1');
button.addEventListener('click', this.toggle.bind(this));
button.innerHTML = this.visibleIcon + ' ' + this.visibleLabelValue;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you test with the registration form on ux.symfony.com, you'll notice the hide/show button will disappear after filling in the password field. That is not a problem with this code - I need to make the live controller not lazy in controllers.json. Just an FYI :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the warning, i would have been really confused otherwise 🤣


return button;
}

/**
* Toggle input type between "text" or "password" and update label accordingly
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this IS a good comment - it adds a bit more info :)

toggle(event: any): void {
this.isDisplayed = !this.isDisplayed;
const toggleButtonElement: HTMLButtonElement = event.currentTarget;
toggleButtonElement.innerHTML = this.isDisplayed
? this.hiddenIcon + ' ' + this.hiddenLabelValue
: this.visibleIcon + ' ' + this.visibleLabelValue;
this.element.setAttribute('type', this.isDisplayed ? 'text' : 'password');
this.dispatchEvent(this.isDisplayed ? 'show' : 'hide', { element: this.element, button: toggleButtonElement });
}

private dispatchEvent(name: string, payload: any): void {
this.dispatch(name, { detail: payload, prefix: 'toggle-password' });
}
}
22 changes: 22 additions & 0 deletions src/TogglePassword/assets/src/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.toggle-password-container {
position: relative;
}
.toggle-password-icon {
height: 1rem;
width: 1rem;
}
.toggle-password-button {
align-items: center;
background-color: transparent;
border: none;
column-gap: 0.25rem;
display: flex;
flex-direction: row;
font-size: 0.875rem;
justify-items: center;
height: 1rem;
line-height: 1.25rem;
position: absolute;
right: 0.5rem;
top: -1.25rem;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to be the trickiest part of this PR. Can we make some CSS that works in "most" cases. I was testing locally on https://ux.symfony.com/live-component/demos/auto-validating-form

I see the form theme template, which contains the toggle-password-container class, which is the key to making this all work "out of the box". I also see that you prepended the config to add this form theme automatically - smart! Unfortunately... because ux.symfony.com already uses a form theme, by prepending the config, the custom bootstrap_5_layout.html.twig from that project shows up last in the list, and so its form theme wins (and the one from this bundle isn't used). I also think that there should probably be a way to "opt out" of the form theme - you may want to use your normal password_widget rendering (without the new div) and then use your own CSS to place things correctly.

I'm not convinced I have the correct solution for this. But my idea is:

A) Remove the prepend and instead document the user adding the form theme manually. It kinda sucks to do this... but then the user can opt out of it.

B) Document that, instead of the form theme, you can also style manually.

Another idea, might be to:

  1. In TogglePasswordTypeExtension, add a new toggle_form_theme option, which defaults to true.
  2. In TogglePasswordTypeExtension, if that's true, add a new item to the block_prefixes variable - like this
    array_splice($view['autocomplete']->vars['block_prefixes'], -1, 0, 'ux_entity_autocomplete_inner');
    - something like ux_password_toggle.
  3. Change the form theme template to override just that block - e.g. ux_password_toggle_widget.

The idea would be that, if toggle_form_theme is true, then it will use your form theme. If false, it will skip and use the normal. Additionally, in your form theme (form_theme.html.twig), I believe you could then:

-{ block('form_widget') }}
+{ block('password_widget') }}

So in the event the user is overriding the password_widget in a custom form theme, we will still use that.

Also, we should document how this all works a bit - basically communicating:

A) There is a custom form theme that's activated, which wraps your widget in a <div class="toggle-password-container">.
B) You can turn this off with the toggle_form_theme option, but then you're on your own to style.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch ! Indeed, that's tricky but absolutely relevant !!
I'd go for your second suggestion, adding a toggle_form_theme boolean option, which would define a block prefix 👍🏼
Let's go for that 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, done !
I went for use_toggle_form_theme option, with true as default value so it can add a custom block prefix : ux_toggle_password (to keep consistency with the names used everywhere else in the package) and i add documentation about the behaviour to expect.
I tried to be as clear as possible on this one while while remaining as concise as possible. I hope it's clear enough, otherwise i'll try another way 😄

}
71 changes: 71 additions & 0 deletions src/TogglePassword/assets/test/controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

'use strict';

import { Application, Controller } from '@hotwired/stimulus';
import { getByTestId, waitFor, getByText } from '@testing-library/dom';
import user from '@testing-library/user-event';
import { clearDOM, mountDOM } from '@symfony/stimulus-testing';
import TogglePasswordController from '../src/controller';

// Controller used to check the actual controller was properly booted
class CheckController extends Controller {
connect() {
this.element.addEventListener('toggle-password:connect', () => {
this.element.classList.add('connected');
});
}
}

const startStimulus = () => {
const application = Application.start();
application.register('check', CheckController);
application.register('toggle-password', TogglePasswordController);
}

describe('TogglePasswordController', () => {
let container;

beforeEach(() => {
container = mountDOM(`
<div class="toggle-password-container">
<input type="password"
data-testid="input"
data-controller="check toggle-password"
data-toggle-password-hidden-label-value="Hide"
data-toggle-password-visible-label-value="Show" />
</div>
`);
startStimulus();
});

afterEach(() => {
clearDOM();
});

it('should toggle the input type', async () => {
const input = getByTestId(container, 'input');
const button = getByText(container, 'Show');

expect(input.type).toBe('password');

user.click(button);

await waitFor(() => {
expect(input.type).toBe('text');
});

user.click(button);

await waitFor(() => {
expect(input.type).toBe('password');
});
});
});
Loading