fix(forms): use Alpine state for password visibility toggles
Replace shared `changePasswordFieldType` JS with component-local Alpine logic across input, textarea, and env-var-input components. This keeps toggle behavior consistent, resets visibility on `success` events, and preserves `truncate` styling only when showing plaintext on enabled fields. Also adds `PasswordVisibilityComponentTest` to verify Alpine bindings are rendered and legacy handler references are removed.
This commit is contained in:
parent
9e96a20a49
commit
a5840501b4
5 changed files with 80 additions and 44 deletions
|
|
@ -10,7 +10,7 @@
|
|||
</label>
|
||||
@endif
|
||||
|
||||
<div class="relative" x-data="{
|
||||
<div class="relative" @success.window="type = '{{ $type }}'" x-data="{
|
||||
type: '{{ $type }}',
|
||||
showDropdown: false,
|
||||
suggestions: [],
|
||||
|
|
@ -185,15 +185,23 @@
|
|||
@click.outside="showDropdown = false">
|
||||
|
||||
@if ($type === 'password' && $allowToPeak)
|
||||
<div x-on:click="changePasswordFieldType"
|
||||
class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hover:text-white z-10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
<button type="button" x-on:click="type = type === 'password' ? 'text' : 'password'"
|
||||
class="flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer dark:hover:text-white"
|
||||
aria-label="Toggle password visibility">
|
||||
<svg x-show="type === 'password'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
||||
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<svg x-cloak x-show="type === 'text'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
|
||||
<path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" />
|
||||
<path d="M3 3l18 18" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<input
|
||||
|
|
@ -202,6 +210,8 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
|
|||
@keydown="handleKeydown($event)"
|
||||
@click="handleInput()"
|
||||
autocomplete="{{ $autocomplete }}"
|
||||
x-bind:type="type"
|
||||
x-bind:class="{ 'truncate': type === 'text' && ! $el.disabled }"
|
||||
{{ $attributes->merge(['class' => $defaultClass]) }}
|
||||
@required($required)
|
||||
@readonly($readonly)
|
||||
|
|
@ -210,12 +220,10 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
|
|||
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"
|
||||
@endif
|
||||
wire:loading.attr="disabled"
|
||||
@if ($type === 'password')
|
||||
:type="type"
|
||||
@else
|
||||
@disabled($disabled)
|
||||
@if ($type !== 'password')
|
||||
type="{{ $type }}"
|
||||
@endif
|
||||
@disabled($disabled)
|
||||
@if ($htmlId !== 'null') id="{{ $htmlId }}" @endif
|
||||
name="{{ $name }}"
|
||||
placeholder="{{ $attributes->get('placeholder') }}"
|
||||
|
|
|
|||
|
|
@ -13,10 +13,11 @@
|
|||
</label>
|
||||
@endif
|
||||
@if ($type === 'password')
|
||||
<div class="relative" x-data="{ type: 'password' }">
|
||||
<div class="relative" x-data="{ type: 'password' }" @success.window="type = 'password'">
|
||||
@if ($allowToPeak)
|
||||
<div x-on:click="changePasswordFieldType; type = type === 'password' ? 'text' : 'password'"
|
||||
class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hover:text-white">
|
||||
<button type="button" x-on:click="type = type === 'password' ? 'text' : 'password'"
|
||||
class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hover:text-white"
|
||||
aria-label="Toggle password visibility">
|
||||
{{-- Eye icon (shown when password is hidden) --}}
|
||||
<svg x-show="type === 'password'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
|
|
@ -32,13 +33,15 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
|
|||
<path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" />
|
||||
<path d="M3 3l18 18" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
@endif
|
||||
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
|
||||
x-bind:type="type"
|
||||
x-bind:class="{ 'truncate': type === 'text' && ! $el.disabled }"
|
||||
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
|
||||
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
|
||||
wire:loading.attr="disabled"
|
||||
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
|
||||
@readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
|
||||
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
|
||||
aria-placeholder="{{ $attributes->get('placeholder') }}"
|
||||
@if ($autofocus) x-ref="autofocusInput" @endif>
|
||||
|
|
|
|||
|
|
@ -30,18 +30,26 @@ function handleKeydown(e) {
|
|||
readonly="{{ $readonly }}" label="dockerfile" autofocus="{{ $autofocus }}" />
|
||||
@else
|
||||
@if ($type === 'password')
|
||||
<div class="relative" x-data="{ type: 'password' }">
|
||||
<div class="relative" x-data="{ type: 'password' }" @success.window="type = 'password'">
|
||||
@if ($allowToPeak)
|
||||
<div x-on:click="changePasswordFieldType"
|
||||
class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer dark:hover:text-white">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
<button type="button" x-on:click="type = type === 'password' ? 'text' : 'password'"
|
||||
class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer dark:hover:text-white"
|
||||
aria-label="Toggle password visibility">
|
||||
<svg x-show="type === 'password'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
||||
<path
|
||||
d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<svg x-cloak x-show="type === 'text'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
|
||||
<path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" />
|
||||
<path d="M3 3l18 18" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
<input x-cloak x-show="type === 'password'" value="{{ $value }}"
|
||||
{{ $attributes->merge(['class' => $defaultClassInput]) }} @required($required)
|
||||
|
|
|
|||
|
|
@ -203,30 +203,6 @@ function checkTheme() {
|
|||
let checkHealthInterval = null;
|
||||
let checkIfIamDeadInterval = null;
|
||||
|
||||
function changePasswordFieldType(event) {
|
||||
let element = event.target
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (element.className === "relative") {
|
||||
break;
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
element = element.children[1];
|
||||
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
|
||||
if (element.type === 'password') {
|
||||
element.type = 'text';
|
||||
if (element.disabled) return;
|
||||
element.classList.add('truncate');
|
||||
this.type = 'text';
|
||||
} else {
|
||||
element.type = 'password';
|
||||
if (element.disabled) return;
|
||||
element.classList.remove('truncate');
|
||||
this.type = 'password';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator?.clipboard?.writeText(text) && window.Livewire.dispatch('success', 'Copied to clipboard.');
|
||||
}
|
||||
|
|
@ -326,4 +302,4 @@ function copyToClipboard(text) {
|
|||
</body>
|
||||
@show
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
|||
41
tests/Feature/PasswordVisibilityComponentTest.php
Normal file
41
tests/Feature/PasswordVisibilityComponentTest.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\MessageBag;
|
||||
use Illuminate\Support\ViewErrorBag;
|
||||
|
||||
beforeEach(function () {
|
||||
$errors = new ViewErrorBag;
|
||||
$errors->put('default', new MessageBag);
|
||||
view()->share('errors', $errors);
|
||||
});
|
||||
|
||||
it('renders password input with Alpine-managed visibility state', function () {
|
||||
$html = Blade::render('<x-forms.input type="password" id="secret" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('@success.window="type = \'password\'"')
|
||||
->toContain("x-data=\"{ type: 'password' }\"")
|
||||
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
|
||||
->toContain('x-bind:type="type"')
|
||||
->toContain("x-bind:class=\"{ 'truncate': type === 'text' && ! \$el.disabled }\"")
|
||||
->not->toContain('changePasswordFieldType');
|
||||
});
|
||||
|
||||
it('renders password textarea with Alpine-managed visibility state', function () {
|
||||
$html = Blade::render('<x-forms.textarea type="password" id="secret" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('@success.window="type = \'password\'"')
|
||||
->toContain("x-data=\"{ type: 'password' }\"")
|
||||
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
|
||||
->not->toContain('changePasswordFieldType');
|
||||
});
|
||||
|
||||
it('resets password visibility on success event for env-var-input', function () {
|
||||
$html = Blade::render('<x-forms.env-var-input type="password" id="secret" />');
|
||||
|
||||
expect($html)
|
||||
->toContain("@success.window=\"type = 'password'\"")
|
||||
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
|
||||
->toContain('x-bind:type="type"');
|
||||
});
|
||||
Loading…
Reference in a new issue