mirror of
https://github.com/tiennm99/coolify.git
synced 2026-05-12 20:58:05 +00:00
Fix duplicate HTML ID warnings in form components
Resolve browser console warnings about non-unique HTML IDs when multiple Livewire components with similar form fields appear on the same page. **Problem:** Multiple forms using generic IDs like `id="description"` or `id="name"` caused duplicate ID warnings and potential accessibility/JavaScript issues. **Solution:** - Separate `wire:model` binding name from HTML `id` attribute - Auto-prefix HTML IDs with Livewire component ID for uniqueness - Preserve existing `wire:model` behavior with property names **Implementation:** - Added `$modelBinding` property for wire:model (e.g., "description") - Added `$htmlId` property for unique HTML ID (e.g., "lw-xyz123-description") - Updated render() method to generate unique IDs automatically - Updated all blade templates to use new properties **Components Updated:** - Input (text, password, etc.) - Textarea (including Monaco editor) - Select - Checkbox - Datalist (single & multiple selection) **Result:** ✅ All HTML IDs now unique across page ✅ No console warnings ✅ wire:model bindings work correctly ✅ Validation error messages display correctly ✅ Backward compatible - no changes needed in existing components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,10 @@ use Illuminate\View\Component;
|
||||
|
||||
class Checkbox extends Component
|
||||
{
|
||||
public ?string $modelBinding = null;
|
||||
|
||||
public ?string $htmlId = null;
|
||||
|
||||
/**
|
||||
* Create a new component instance.
|
||||
*/
|
||||
@@ -47,6 +51,18 @@ class Checkbox extends Component
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
// Store original ID for wire:model binding (property name)
|
||||
$this->modelBinding = $this->id;
|
||||
$this->htmlId = $this->id;
|
||||
|
||||
// Generate unique HTML ID by prefixing with Livewire component ID if available
|
||||
if ($this->id) {
|
||||
$livewireId = $this->attributes?->wire('id');
|
||||
if ($livewireId) {
|
||||
$this->htmlId = $livewireId.'-'.$this->id;
|
||||
}
|
||||
}
|
||||
|
||||
return view('components.forms.checkbox');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Datalist extends Component
|
||||
{
|
||||
public ?string $modelBinding = null;
|
||||
|
||||
public ?string $htmlId = null;
|
||||
|
||||
/**
|
||||
* Create a new component instance.
|
||||
*/
|
||||
@@ -47,11 +51,25 @@ class Datalist extends Component
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
// Store original ID for wire:model binding (property name)
|
||||
$this->modelBinding = $this->id;
|
||||
|
||||
if (is_null($this->id)) {
|
||||
$this->id = new Cuid2;
|
||||
$this->modelBinding = $this->id;
|
||||
}
|
||||
|
||||
// Generate unique HTML ID by prefixing with Livewire component ID
|
||||
// This prevents duplicate IDs when multiple forms are on the same page
|
||||
$livewireId = $this->attributes?->wire('id');
|
||||
if ($livewireId && $this->modelBinding) {
|
||||
$this->htmlId = $livewireId.'-'.$this->modelBinding;
|
||||
} else {
|
||||
$this->htmlId = $this->modelBinding ?: $this->id;
|
||||
}
|
||||
|
||||
if (is_null($this->name)) {
|
||||
$this->name = $this->id;
|
||||
$this->name = $this->modelBinding;
|
||||
}
|
||||
|
||||
return view('components.forms.datalist');
|
||||
|
||||
@@ -10,6 +10,10 @@ use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Input extends Component
|
||||
{
|
||||
public ?string $modelBinding = null;
|
||||
|
||||
public ?string $htmlId = null;
|
||||
|
||||
public function __construct(
|
||||
public ?string $id = null,
|
||||
public ?string $name = null,
|
||||
@@ -43,11 +47,24 @@ class Input extends Component
|
||||
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
// Store original ID for wire:model binding (property name)
|
||||
$this->modelBinding = $this->id;
|
||||
|
||||
if (is_null($this->id)) {
|
||||
$this->id = new Cuid2;
|
||||
$this->modelBinding = $this->id;
|
||||
}
|
||||
// Generate unique HTML ID by prefixing with Livewire component ID
|
||||
// This prevents duplicate IDs when multiple forms are on the same page
|
||||
$livewireId = $this->attributes?->wire('id');
|
||||
if ($livewireId && $this->modelBinding) {
|
||||
$this->htmlId = $livewireId.'-'.$this->modelBinding;
|
||||
} else {
|
||||
$this->htmlId = $this->modelBinding ?: $this->id;
|
||||
}
|
||||
|
||||
if (is_null($this->name)) {
|
||||
$this->name = $this->id;
|
||||
$this->name = $this->modelBinding;
|
||||
}
|
||||
if ($this->type === 'password') {
|
||||
$this->defaultClass = $this->defaultClass.' pr-[2.8rem]';
|
||||
|
||||
@@ -10,6 +10,10 @@ use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Select extends Component
|
||||
{
|
||||
public ?string $modelBinding = null;
|
||||
|
||||
public ?string $htmlId = null;
|
||||
|
||||
/**
|
||||
* Create a new component instance.
|
||||
*/
|
||||
@@ -40,11 +44,25 @@ class Select extends Component
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
// Store original ID for wire:model binding (property name)
|
||||
$this->modelBinding = $this->id;
|
||||
|
||||
if (is_null($this->id)) {
|
||||
$this->id = new Cuid2;
|
||||
$this->modelBinding = $this->id;
|
||||
}
|
||||
|
||||
// Generate unique HTML ID by prefixing with Livewire component ID
|
||||
// This prevents duplicate IDs when multiple forms are on the same page
|
||||
$livewireId = $this->attributes?->wire('id');
|
||||
if ($livewireId && $this->modelBinding) {
|
||||
$this->htmlId = $livewireId.'-'.$this->modelBinding;
|
||||
} else {
|
||||
$this->htmlId = $this->modelBinding ?: $this->id;
|
||||
}
|
||||
|
||||
if (is_null($this->name)) {
|
||||
$this->name = $this->id;
|
||||
$this->name = $this->modelBinding;
|
||||
}
|
||||
|
||||
return view('components.forms.select');
|
||||
|
||||
@@ -10,6 +10,10 @@ use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Textarea extends Component
|
||||
{
|
||||
public ?string $modelBinding = null;
|
||||
|
||||
public ?string $htmlId = null;
|
||||
|
||||
/**
|
||||
* Create a new component instance.
|
||||
*/
|
||||
@@ -53,11 +57,25 @@ class Textarea extends Component
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
// Store original ID for wire:model binding (property name)
|
||||
$this->modelBinding = $this->id;
|
||||
|
||||
if (is_null($this->id)) {
|
||||
$this->id = new Cuid2;
|
||||
$this->modelBinding = $this->id;
|
||||
}
|
||||
|
||||
// Generate unique HTML ID by prefixing with Livewire component ID
|
||||
// This prevents duplicate IDs when multiple forms are on the same page
|
||||
$livewireId = $this->attributes?->wire('id');
|
||||
if ($livewireId && $this->modelBinding) {
|
||||
$this->htmlId = $livewireId.'-'.$this->modelBinding;
|
||||
} else {
|
||||
$this->htmlId = $this->modelBinding ?: $this->id;
|
||||
}
|
||||
|
||||
if (is_null($this->name)) {
|
||||
$this->name = $this->id;
|
||||
$this->name = $this->modelBinding;
|
||||
}
|
||||
|
||||
// $this->label = Str::title($this->label);
|
||||
|
||||
@@ -32,14 +32,14 @@
|
||||
<input type="checkbox" @disabled($disabled) {{ $attributes->merge(['class' => $defaultClass]) }}
|
||||
wire:loading.attr="disabled"
|
||||
wire:click='{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}'
|
||||
wire:model={{ $id }} @if ($checked) checked @endif />
|
||||
wire:model={{ $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif />
|
||||
@else
|
||||
@if ($domValue)
|
||||
<input type="checkbox" @disabled($disabled) {{ $attributes->merge(['class' => $defaultClass]) }}
|
||||
value={{ $domValue }} @if ($checked) checked @endif />
|
||||
value={{ $domValue }} id="{{ $htmlId }}" @if ($checked) checked @endif />
|
||||
@else
|
||||
<input type="checkbox" @disabled($disabled) {{ $attributes->merge(['class' => $defaultClass]) }}
|
||||
wire:model={{ $value ?? $id }} @if ($checked) checked @endif />
|
||||
wire:model={{ $value ?? $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif />
|
||||
@endif
|
||||
@endif
|
||||
</label>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div x-data="{
|
||||
open: false,
|
||||
search: '',
|
||||
selected: @entangle($id).live,
|
||||
selected: @entangle($modelBinding).live,
|
||||
options: [],
|
||||
filteredOptions: [],
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
<div x-data="{
|
||||
open: false,
|
||||
search: '',
|
||||
selected: @entangle(($attributes->whereStartsWith('wire:model')->first() ? $attributes->wire('model')->value() : $id)).live,
|
||||
selected: @entangle(($attributes->whereStartsWith('wire:model')->first() ? $attributes->wire('model')->value() : $modelBinding)).live,
|
||||
options: [],
|
||||
filteredOptions: [],
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@error($id)
|
||||
@error($modelBinding)
|
||||
<label class="label">
|
||||
<span class="text-red-500 label-text-alt">{{ $message }}</span>
|
||||
</label>
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
@endif
|
||||
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
|
||||
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
|
||||
@if ($id !== 'null') wire:model={{ $id }} @endif
|
||||
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} @endif
|
||||
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled"
|
||||
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}"
|
||||
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
|
||||
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
|
||||
aria-placeholder="{{ $attributes->get('placeholder') }}"
|
||||
@if ($autofocus) x-ref="autofocusInput" @endif>
|
||||
@@ -38,19 +38,19 @@
|
||||
@else
|
||||
<input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif
|
||||
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly)
|
||||
@if ($id !== 'null') wire:model={{ $id }} @endif
|
||||
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} @endif
|
||||
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled"
|
||||
type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}"
|
||||
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"
|
||||
maxlength="{{ $attributes->get('maxlength') }}"
|
||||
@if ($id !== 'null') id={{ $id }} @endif name="{{ $name }}"
|
||||
@if ($htmlId !== 'null') id={{ $htmlId }} @endif name="{{ $name }}"
|
||||
placeholder="{{ $attributes->get('placeholder') }}"
|
||||
@if ($autofocus) x-ref="autofocusInput" @endif>
|
||||
@endif
|
||||
@if (!$label && $helper)
|
||||
<x-helper :helper="$helper" />
|
||||
@endif
|
||||
@error($id)
|
||||
@error($modelBinding)
|
||||
<label class="label">
|
||||
<span class="text-red-500 label-text-alt">{{ $message }}</span>
|
||||
</label>
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
</label>
|
||||
@endif
|
||||
<select {{ $attributes->merge(['class' => $defaultClass]) }} @disabled($disabled) @required($required)
|
||||
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" name={{ $id }}
|
||||
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} @else wire:model={{ $id }} @endif>
|
||||
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" name={{ $modelBinding }} id="{{ $htmlId }}"
|
||||
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} @else wire:model={{ $modelBinding }} @endif>
|
||||
{{ $slot }}
|
||||
</select>
|
||||
@error($id)
|
||||
@error($modelBinding)
|
||||
<label class="label">
|
||||
<span class="text-red-500 label-text-alt">{{ $message }}</span>
|
||||
</label>
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
</label>
|
||||
@endif
|
||||
@if ($useMonacoEditor)
|
||||
<x-forms.monaco-editor id="{{ $id }}" language="{{ $monacoEditorLanguage }}" name="{{ $name }}"
|
||||
name="{{ $id }}" model="{{ $value ?? $id }}" wire:model="{{ $value ?? $id }}"
|
||||
<x-forms.monaco-editor id="{{ $htmlId }}" language="{{ $monacoEditorLanguage }}" name="{{ $name }}"
|
||||
name="{{ $modelBinding }}" model="{{ $value ?? $modelBinding }}" wire:model="{{ $value ?? $modelBinding }}"
|
||||
readonly="{{ $readonly }}" label="dockerfile" />
|
||||
@else
|
||||
@if ($type === 'password')
|
||||
@@ -45,34 +45,34 @@
|
||||
@endif
|
||||
<input x-cloak x-show="type === 'password'" value="{{ $value }}"
|
||||
{{ $attributes->merge(['class' => $defaultClassInput]) }} @required($required)
|
||||
@if ($id !== 'null') wire:model={{ $id }} @endif
|
||||
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} @endif
|
||||
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled"
|
||||
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}"
|
||||
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
|
||||
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
|
||||
aria-placeholder="{{ $attributes->get('placeholder') }}">
|
||||
<textarea minlength="{{ $minlength }}" maxlength="{{ $maxlength }}" x-cloak x-show="type !== 'password'"
|
||||
placeholder="{{ $placeholder }}" {{ $attributes->merge(['class' => $defaultClass]) }}
|
||||
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $id }}"
|
||||
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}"
|
||||
@else
|
||||
wire:model={{ $value ?? $id }}
|
||||
wire:model={{ $value ?? $modelBinding }}
|
||||
wire:dirty.class="dark:ring-warning ring-warning" @endif
|
||||
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $id }}"
|
||||
name="{{ $name }}" name={{ $id }}></textarea>
|
||||
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
|
||||
name="{{ $name }}" name={{ $modelBinding }}></textarea>
|
||||
|
||||
</div>
|
||||
@else
|
||||
<textarea minlength="{{ $minlength }}" maxlength="{{ $maxlength }}"
|
||||
{{ $allowTab ? '@keydown.tab=handleKeydown' : '' }} placeholder="{{ $placeholder }}"
|
||||
{{ !$spellcheck ? 'spellcheck=false' : '' }} {{ $attributes->merge(['class' => $defaultClass]) }}
|
||||
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $id }}"
|
||||
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}"
|
||||
@else
|
||||
wire:model={{ $value ?? $id }}
|
||||
wire:model={{ $value ?? $modelBinding }}
|
||||
wire:dirty.class="dark:ring-warning ring-warning" @endif
|
||||
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $id }}"
|
||||
name="{{ $name }}" name={{ $id }}></textarea>
|
||||
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
|
||||
name="{{ $name }}" name={{ $modelBinding }}></textarea>
|
||||
@endif
|
||||
@endif
|
||||
@error($id)
|
||||
@error($modelBinding)
|
||||
<label class="label">
|
||||
<span class="text-red-500 label-text-alt">{{ $message }}</span>
|
||||
</label>
|
||||
|
||||
Reference in New Issue
Block a user