2024-04-26 08:33:55 +00:00
|
|
|
<div class="w-full">
|
2025-10-10 10:17:05 +00:00
|
|
|
@if ($label)
|
|
|
|
|
<label class="flex gap-1 items-center mb-1 text-sm font-medium {{ $disabled ? 'text-neutral-600' : '' }}">
|
2024-04-26 08:33:55 +00:00
|
|
|
{{ $label }}
|
|
|
|
|
@if ($required)
|
|
|
|
|
<x-highlighted text="*" />
|
|
|
|
|
@endif
|
|
|
|
|
@if ($helper)
|
|
|
|
|
<x-helper :helper="$helper" />
|
|
|
|
|
@endif
|
2025-10-10 10:17:05 +00:00
|
|
|
</label>
|
|
|
|
|
@endif
|
|
|
|
|
|
|
|
|
|
@if ($multiple)
|
|
|
|
|
{{-- Multiple Selection Mode with Alpine.js --}}
|
|
|
|
|
<div x-data="{
|
2025-10-23 14:18:20 +00:00
|
|
|
open: false,
|
|
|
|
|
search: '',
|
|
|
|
|
selected: @entangle($modelBinding).live,
|
|
|
|
|
options: [],
|
|
|
|
|
filteredOptions: [],
|
|
|
|
|
|
|
|
|
|
init() {
|
|
|
|
|
this.options = Array.from(this.$refs.datalist.querySelectorAll('option')).map(opt => {
|
|
|
|
|
// Try to parse as integer, fallback to string
|
|
|
|
|
let value = opt.value;
|
|
|
|
|
const intValue = parseInt(value, 10);
|
|
|
|
|
if (!isNaN(intValue) && intValue.toString() === value) {
|
|
|
|
|
value = intValue;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
value: value,
|
|
|
|
|
text: opt.textContent.trim()
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
this.filteredOptions = this.options;
|
|
|
|
|
// Ensure selected is always an array
|
|
|
|
|
if (!Array.isArray(this.selected)) {
|
|
|
|
|
this.selected = [];
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
filterOptions() {
|
|
|
|
|
if (!this.search) {
|
|
|
|
|
this.filteredOptions = this.options;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const searchLower = this.search.toLowerCase();
|
|
|
|
|
this.filteredOptions = this.options.filter(opt =>
|
|
|
|
|
opt.text.toLowerCase().includes(searchLower)
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
toggleOption(value) {
|
|
|
|
|
// Ensure selected is an array
|
|
|
|
|
if (!Array.isArray(this.selected)) {
|
|
|
|
|
this.selected = [];
|
|
|
|
|
}
|
|
|
|
|
const index = this.selected.indexOf(value);
|
|
|
|
|
if (index > -1) {
|
|
|
|
|
this.selected.splice(index, 1);
|
|
|
|
|
} else {
|
|
|
|
|
this.selected.push(value);
|
|
|
|
|
}
|
|
|
|
|
this.search = '';
|
|
|
|
|
this.filterOptions();
|
|
|
|
|
// Focus input after selection
|
|
|
|
|
this.$refs.searchInput.focus();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
removeOption(value, event) {
|
|
|
|
|
// Ensure selected is an array
|
|
|
|
|
if (!Array.isArray(this.selected)) {
|
|
|
|
|
this.selected = [];
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Prevent triggering container click
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
const index = this.selected.indexOf(value);
|
|
|
|
|
if (index > -1) {
|
|
|
|
|
this.selected.splice(index, 1);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
isSelected(value) {
|
|
|
|
|
// Ensure selected is an array
|
|
|
|
|
if (!Array.isArray(this.selected)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return this.selected.includes(value);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getSelectedText(value) {
|
|
|
|
|
const option = this.options.find(opt => opt.value == value);
|
|
|
|
|
return option ? option.text : value;
|
|
|
|
|
}
|
|
|
|
|
}" @click.outside="open = false" class="relative">
|
|
|
|
|
|
|
|
|
|
{{-- Unified Input Container with Tags Inside --}}
|
|
|
|
|
<div @click="$refs.searchInput.focus()"
|
|
|
|
|
class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 px-2 w-full text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text px-1 focus-within:border-l-4 focus-within:border-l-coollabs dark:focus-within:border-l-warning text-black dark:text-white"
|
|
|
|
|
:class="{
|
|
|
|
|
'opacity-50': {{ $disabled ? 'true' : 'false' }}
|
|
|
|
|
}" wire:loading.class="opacity-50"
|
|
|
|
|
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4">
|
|
|
|
|
|
|
|
|
|
{{-- Selected Tags Inside Input --}}
|
|
|
|
|
<template x-for="value in selected" :key="value">
|
|
|
|
|
<button type="button" @click.stop="removeOption(value, $event)"
|
|
|
|
|
:disabled="{{ $disabled ? 'true' : 'false' }}"
|
|
|
|
|
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs bg-coolgray-200 dark:bg-coolgray-700 rounded whitespace-nowrap {{ $disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-red-100 dark:hover:bg-red-900/20 hover:text-red-600 dark:hover:text-red-400' }}"
|
|
|
|
|
aria-label="Remove">
|
|
|
|
|
<span x-text="getSelectedText(value)" class="max-w-[200px] truncate"></span>
|
|
|
|
|
</button>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
{{-- Search Input (Borderless, Inside Container) --}}
|
|
|
|
|
<input type="text" x-model="search" x-ref="searchInput" @input="filterOptions()" @focus="open = true"
|
|
|
|
|
@keydown.escape="open = false" :placeholder="(Array.isArray(selected) && selected.length > 0) ? '' :
|
|
|
|
|
{{ json_encode($placeholder ?: 'Search...') }}" @required($required) @readonly($readonly)
|
|
|
|
|
@disabled($disabled) @if ($autofocus) autofocus @endif
|
|
|
|
|
class="flex-1 min-w-[120px] text-sm border-0 outline-none bg-transparent p-0 focus:ring-0 placeholder:text-neutral-400 dark:placeholder:text-neutral-600 text-black dark:text-white" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{{-- Dropdown Options --}}
|
|
|
|
|
<div x-show="open && !{{ $disabled ? 'true' : 'false' }}" x-transition
|
|
|
|
|
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg max-h-60 overflow-auto scrollbar">
|
|
|
|
|
|
|
|
|
|
<template x-if="filteredOptions.length === 0">
|
|
|
|
|
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
|
|
|
|
|
No options found
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template x-for="option in filteredOptions" :key="option.value">
|
|
|
|
|
<div @click="toggleOption(option.value)"
|
|
|
|
|
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-3"
|
|
|
|
|
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': isSelected(option.value) }">
|
|
|
|
|
<input type="checkbox" :checked="isSelected(option.value)"
|
|
|
|
|
class="w-4 h-4 rounded border-neutral-300 dark:border-neutral-600 bg-white dark:bg-coolgray-100 text-black dark:text-white checked:bg-white dark:checked:bg-coolgray-100 focus:ring-coollabs dark:focus:ring-warning pointer-events-none"
|
|
|
|
|
tabindex="-1">
|
|
|
|
|
<span class="text-sm flex-1" x-text="option.text"></span>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{{-- Hidden datalist for options --}}
|
|
|
|
|
<datalist x-ref="datalist" style="display: none;">
|
|
|
|
|
{{ $slot }}
|
|
|
|
|
</datalist>
|
|
|
|
|
</div>
|
|
|
|
|
@else
|
|
|
|
|
{{-- Single Selection Mode with Alpine.js --}}
|
|
|
|
|
<div x-data="{
|
2025-10-10 10:17:05 +00:00
|
|
|
open: false,
|
|
|
|
|
search: '',
|
2025-10-23 14:18:20 +00:00
|
|
|
selected: @entangle(($attributes->whereStartsWith('wire:model')->first() ? $attributes->wire('model')->value() : $modelBinding)).live,
|
2025-10-10 10:17:05 +00:00
|
|
|
options: [],
|
|
|
|
|
filteredOptions: [],
|
2025-10-23 14:18:20 +00:00
|
|
|
|
2025-10-10 10:17:05 +00:00
|
|
|
init() {
|
|
|
|
|
this.options = Array.from(this.$refs.datalist.querySelectorAll('option')).map(opt => {
|
2025-10-23 14:18:20 +00:00
|
|
|
// Skip disabled options
|
|
|
|
|
if (opt.disabled) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2025-10-10 10:17:05 +00:00
|
|
|
// Try to parse as integer, fallback to string
|
|
|
|
|
let value = opt.value;
|
|
|
|
|
const intValue = parseInt(value, 10);
|
|
|
|
|
if (!isNaN(intValue) && intValue.toString() === value) {
|
|
|
|
|
value = intValue;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
value: value,
|
|
|
|
|
text: opt.textContent.trim()
|
|
|
|
|
};
|
2025-10-23 14:18:20 +00:00
|
|
|
}).filter(opt => opt !== null);
|
2025-10-10 10:17:05 +00:00
|
|
|
this.filteredOptions = this.options;
|
|
|
|
|
},
|
2025-10-23 14:18:20 +00:00
|
|
|
|
2025-10-10 10:17:05 +00:00
|
|
|
filterOptions() {
|
|
|
|
|
if (!this.search) {
|
|
|
|
|
this.filteredOptions = this.options;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const searchLower = this.search.toLowerCase();
|
|
|
|
|
this.filteredOptions = this.options.filter(opt =>
|
|
|
|
|
opt.text.toLowerCase().includes(searchLower)
|
|
|
|
|
);
|
|
|
|
|
},
|
2025-10-23 14:18:20 +00:00
|
|
|
|
|
|
|
|
selectOption(value) {
|
|
|
|
|
this.selected = value;
|
2025-10-10 10:17:05 +00:00
|
|
|
this.search = '';
|
2025-10-23 14:18:20 +00:00
|
|
|
this.open = false;
|
2025-10-10 10:17:05 +00:00
|
|
|
this.filterOptions();
|
|
|
|
|
},
|
2025-10-10 13:40:15 +00:00
|
|
|
|
2025-10-23 14:18:20 +00:00
|
|
|
openDropdown() {
|
|
|
|
|
if ({{ $disabled ? 'true' : 'false' }}) return;
|
|
|
|
|
this.open = true;
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
if (this.$refs.searchInput) {
|
|
|
|
|
this.$refs.searchInput.focus();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
},
|
2025-10-12 12:57:45 +00:00
|
|
|
|
2025-10-23 14:18:20 +00:00
|
|
|
getSelectedText() {
|
|
|
|
|
if (!this.selected || this.selected === 'default') return '';
|
|
|
|
|
const option = this.options.find(opt => opt.value == this.selected);
|
|
|
|
|
return option ? option.text : this.selected;
|
|
|
|
|
},
|
2025-10-12 12:57:45 +00:00
|
|
|
|
2025-10-23 14:18:20 +00:00
|
|
|
isDefaultValue() {
|
|
|
|
|
return !this.selected || this.selected === 'default' || this.selected === '';
|
2025-10-12 12:57:45 +00:00
|
|
|
}
|
2025-10-23 14:18:20 +00:00
|
|
|
}" @click.outside="open = false" class="relative">
|
2025-10-12 12:57:45 +00:00
|
|
|
|
2025-10-23 14:18:20 +00:00
|
|
|
{{-- Hidden input for form validation --}}
|
|
|
|
|
<input type="hidden" :value="selected" @required($required) />
|
2025-10-12 12:57:45 +00:00
|
|
|
|
2025-10-23 14:18:20 +00:00
|
|
|
{{-- Input Container --}}
|
|
|
|
|
<div @click="openDropdown()"
|
|
|
|
|
class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text focus-within:border-l-4 focus-within:border-l-coollabs dark:focus-within:border-l-warning text-black dark:text-white"
|
|
|
|
|
:class="{
|
|
|
|
|
'opacity-50': {{ $disabled ? 'true' : 'false' }}
|
|
|
|
|
}" wire:loading.class="opacity-50" wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4">
|
2025-10-12 12:57:45 +00:00
|
|
|
|
2025-10-23 14:18:20 +00:00
|
|
|
{{-- Display Selected Value or Search Input --}}
|
|
|
|
|
<div class="flex-1 flex items-center min-w-0 px-1">
|
|
|
|
|
<template x-if="!isDefaultValue() && !open">
|
|
|
|
|
<span class="text-sm flex-1 truncate text-black dark:text-white px-2"
|
|
|
|
|
x-text="getSelectedText()"></span>
|
|
|
|
|
</template>
|
|
|
|
|
<input type="text" x-show="isDefaultValue() || open" x-model="search" x-ref="searchInput"
|
|
|
|
|
@input="filterOptions()" @focus="open = true" @keydown.escape="open = false"
|
|
|
|
|
:placeholder="{{ json_encode($placeholder ?: 'Search...') }}" @readonly($readonly)
|
|
|
|
|
@disabled($disabled) @if ($autofocus) autofocus @endif
|
|
|
|
|
class="flex-1 text-sm border-0 outline-none bg-transparent p-0 focus:ring-0 placeholder:text-neutral-400 dark:placeholder:text-neutral-600 text-black dark:text-white px-2" />
|
|
|
|
|
</div>
|
2025-10-12 12:57:45 +00:00
|
|
|
|
2025-10-23 14:18:20 +00:00
|
|
|
{{-- Dropdown Arrow --}}
|
|
|
|
|
<button type="button" @click.stop="open = !open" :disabled="{{ $disabled ? 'true' : 'false' }}"
|
|
|
|
|
class="shrink-0 text-neutral-400 px-2 {{ $disabled ? 'cursor-not-allowed' : 'cursor-pointer' }}">
|
|
|
|
|
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': open }" fill="none"
|
|
|
|
|
stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-10-12 12:57:45 +00:00
|
|
|
|
2025-10-23 14:18:20 +00:00
|
|
|
{{-- Dropdown Options --}}
|
|
|
|
|
<div x-show="open && !{{ $disabled ? 'true' : 'false' }}" x-transition
|
|
|
|
|
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg max-h-60 overflow-auto scrollbar">
|
2025-10-12 12:57:45 +00:00
|
|
|
|
2025-10-23 14:18:20 +00:00
|
|
|
<template x-if="filteredOptions.length === 0">
|
|
|
|
|
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
|
|
|
|
|
No options found
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
2025-10-12 12:57:45 +00:00
|
|
|
|
2025-10-23 14:18:20 +00:00
|
|
|
<template x-for="option in filteredOptions" :key="option.value">
|
|
|
|
|
<div @click="selectOption(option.value)"
|
|
|
|
|
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200"
|
|
|
|
|
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selected == option.value }">
|
|
|
|
|
<span class="text-sm" x-text="option.text"></span>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
2025-10-12 12:57:45 +00:00
|
|
|
|
2025-10-23 14:18:20 +00:00
|
|
|
{{-- Hidden datalist for options --}}
|
|
|
|
|
<datalist x-ref="datalist" style="display: none;">
|
|
|
|
|
{{ $slot }}
|
|
|
|
|
</datalist>
|
2025-10-12 12:57:45 +00:00
|
|
|
</div>
|
2025-10-23 14:18:20 +00:00
|
|
|
@endif
|
2025-10-10 10:17:05 +00:00
|
|
|
|
2025-10-23 14:18:20 +00:00
|
|
|
@error($modelBinding)
|
|
|
|
|
<label class="label">
|
|
|
|
|
<span class="text-red-500 label-text-alt">{{ $message }}</span>
|
|
|
|
|
</label>
|
|
|
|
|
@enderror
|
|
|
|
|
</div>
|