- Added `Choices` component for dropdowns with search and multi-selection functionality. - Implemented foundational classes for service connection: - `ConnectServiceData` - `ConnectedServiceFactory` - `ConnectedServicesService` - Added `ConnectServiceForm` Livewire component for managing service connections. - Introduced shared utility traits (`HasAttributeHelpers`) and error components. - Added components for generic use: `Input` and `ListItem`.
431 lines
22 KiB
PHP
431 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\View\Components;
|
|
|
|
use App\Concerns\UI\HasAttributeHelpers;
|
|
use Closure;
|
|
use Exception;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\View\Component;
|
|
|
|
/**
|
|
* This allows dropdowns with search, multiple selections, fully inline with server.
|
|
* This is code from MaryUI Choices component, modified to work with our usecase.
|
|
* https://github.com/robsontenorio/mary/blob/main/src/View/Components/Choices.php
|
|
*/
|
|
final class Choices extends Component
|
|
{
|
|
use HasAttributeHelpers;
|
|
|
|
public string $uuid;
|
|
|
|
public function __construct(
|
|
public ?string $id = null,
|
|
public ?string $label = null,
|
|
public ?string $hint = null,
|
|
public ?string $hintClass = 'fieldset-label',
|
|
public ?string $icon = null,
|
|
public ?string $iconRight = null,
|
|
public ?bool $inline = false,
|
|
public ?bool $clearable = false,
|
|
public ?string $prefix = null,
|
|
public ?string $suffix = null,
|
|
public ?bool $searchable = false,
|
|
public ?bool $noProgress = false,
|
|
public ?bool $single = false,
|
|
public ?bool $compact = false,
|
|
public ?string $compactText = 'selected',
|
|
public ?bool $allowAll = false,
|
|
public ?string $debounce = '250ms',
|
|
public ?int $minChars = 0,
|
|
public ?string $allowAllText = 'Select all',
|
|
public ?string $removeAllText = 'Remove all',
|
|
public ?string $searchFunction = 'search',
|
|
public ?string $optionValue = 'id',
|
|
public ?string $optionLabel = 'name',
|
|
public ?string $optionSubLabel = '',
|
|
public ?bool $valuesAsString = false,
|
|
public ?bool $escapeValues = false,
|
|
public ?string $height = 'max-h-64',
|
|
public Collection|array $options = new Collection(),
|
|
public ?string $noResultText = 'No results found.',
|
|
|
|
// Validations
|
|
public ?string $errorField = null,
|
|
public ?string $errorClass = 'text-error',
|
|
public ?bool $omitError = false,
|
|
public ?bool $firstErrorOnly = false,
|
|
|
|
// Slots
|
|
public mixed $item = null,
|
|
public mixed $selection = null,
|
|
public mixed $prepend = null,
|
|
public mixed $append = null
|
|
) {
|
|
$this->uuid = 'component'.md5(serialize($this)).$id;
|
|
|
|
if (($this->allowAll || $this->compact) && ($this->single || $this->searchable)) {
|
|
throw new Exception('`allow-all` and `compact` does not work combined with `single` or `searchable`.');
|
|
}
|
|
}
|
|
|
|
public function getOptionValue($option): mixed
|
|
{
|
|
$value = data_get($option, $this->optionValue);
|
|
|
|
if ($this->valuesAsString) {
|
|
return "'$value'";
|
|
}
|
|
|
|
if ($this->escapeValues) {
|
|
$value = addslashes($value);
|
|
|
|
return "'$value'";
|
|
}
|
|
|
|
return is_numeric($value) && ! str($value)->startsWith('0') ? $value : "'$value'";
|
|
}
|
|
|
|
public function render(): View|Closure|string
|
|
{
|
|
return <<<'HTML'
|
|
<div x-data="{ focused: false, selection: @entangle($attributes->wire('model')) }">
|
|
<div
|
|
@click.outside = "clear()"
|
|
@keyup.esc = "clear()"
|
|
|
|
x-data="{
|
|
options: {{ json_encode($options) }},
|
|
isSingle: {{ json_encode($single) }},
|
|
isSearchable: {{ json_encode($searchable) }},
|
|
isReadonly: {{ json_encode($isReadonly()) }},
|
|
isDisabled: {{ json_encode($isDisabled()) }},
|
|
isRequired: {{ json_encode($isRequired()) }},
|
|
minChars: {{ $minChars }},
|
|
|
|
init() {
|
|
// Fix weird issue when navigating back
|
|
document.addEventListener('livewire:navigating', () => {
|
|
let elements = document.querySelectorAll('.choices-element');
|
|
elements.forEach(el => el.remove());
|
|
});
|
|
},
|
|
get selectedOptions() {
|
|
return this.isSingle
|
|
? this.options.filter(i => i.{{ $optionValue }} == this.selection)
|
|
: this.selection.map(i => this.options.filter(o => o.{{ $optionValue }} == i)[0])
|
|
},
|
|
get noResults() {
|
|
if (!this.isSearchable || this.$refs.searchInput.value == '') {
|
|
return false
|
|
}
|
|
|
|
return this.isSingle
|
|
? (this.selection && this.options.length == 1) || (!this.selection && this.options.length == 0)
|
|
: this.options.length <= this.selection.length
|
|
},
|
|
get isAllSelected() {
|
|
return this.options.length == this.selection.length
|
|
},
|
|
get isSelectionEmpty() {
|
|
return this.isSingle
|
|
? this.selection == null || this.selection == ''
|
|
: this.selection.length == 0
|
|
},
|
|
selectAll() {
|
|
this.selection = this.options.map(i => i.{{ $optionValue }})
|
|
this.dispatchChangeEvent({ value: this.selection })
|
|
},
|
|
clear() {
|
|
this.focused = false;
|
|
this.$refs.searchInput.value = ''
|
|
},
|
|
reset() {
|
|
this.clear();
|
|
this.isSingle
|
|
? this.selection = null
|
|
: this.selection = []
|
|
|
|
this.dispatchChangeEvent({ value: this.selection})
|
|
},
|
|
focus() {
|
|
if (this.isReadonly || this.isDisabled) {
|
|
return
|
|
}
|
|
|
|
this.focused = true
|
|
this.$refs.searchInput.focus()
|
|
},
|
|
resize() {
|
|
$nextTick(() => $refs.searchInput.style.width = ($refs.searchInput.value.length + 1) * 0.55 + 'rem')
|
|
},
|
|
isActive(id) {
|
|
return this.isSingle
|
|
? this.selection == id
|
|
: this.selection.includes(id)
|
|
},
|
|
toggle(id, keepOpen = false) {
|
|
if (this.isReadonly || this.isDisabled) {
|
|
return
|
|
}
|
|
|
|
if (this.isSingle) {
|
|
this.selection = id
|
|
this.focused = false
|
|
} else {
|
|
this.selection.includes(id)
|
|
? this.selection = this.selection.filter(i => i != id)
|
|
: this.selection.push(id)
|
|
}
|
|
|
|
this.dispatchChangeEvent({ value: this.selection })
|
|
|
|
this.$refs.searchInput.value = ''
|
|
|
|
if (!keepOpen) {
|
|
this.$refs.searchInput.focus()
|
|
}
|
|
|
|
},
|
|
search(value, event) {
|
|
if (value.length < this.minChars) {
|
|
return
|
|
}
|
|
|
|
// Prevent search for this keys
|
|
if (event && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Shift', 'CapsLock', 'Tab',
|
|
'Control', 'Alt', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) {
|
|
return;
|
|
}
|
|
|
|
// Call search function from parent component
|
|
// `search(value)` or `search(value, extra1, extra2 ...)`
|
|
@this.{{ str_contains($searchFunction, '(')
|
|
? preg_replace('/\((.*?)\)/', '(value, $1)', $searchFunction)
|
|
: $searchFunction . '(value)'
|
|
}}.then(()=> this.resize())
|
|
},
|
|
dispatchChangeEvent(detail) {
|
|
this.$refs.searchInput.dispatchEvent(new CustomEvent('change-selection', { bubbles: true, detail }))
|
|
}
|
|
}"
|
|
|
|
@keydown.up="$focus.previous()"
|
|
@keydown.down="$focus.next()"
|
|
>
|
|
<fieldset class="flex flex-col py-0">
|
|
{{-- STANDARD LABEL --}}
|
|
@if($label && !$inline)
|
|
<legend class="text-sm font-medium text-gray-900 mb-2 ml-1">
|
|
{{ $label }}
|
|
|
|
@if($attributes->get('required'))
|
|
<span class="text-red-400">*</span>
|
|
@endif
|
|
|
|
</legend>
|
|
@endif
|
|
|
|
<label @class(["floating-label" => $label && $inline])>
|
|
{{-- FLOATING LABEL--}}
|
|
@if ($label && $inline)
|
|
<span class="font-semibold">{{ $label }}</span>
|
|
@endif
|
|
|
|
<div @class(["w-full", "join" => $prepend || $append])>
|
|
{{-- PREPEND --}}
|
|
@if($prepend)
|
|
{{ $prepend }}
|
|
@endif
|
|
|
|
{{-- THE LABEL THAT HOLDS THE INPUT --}}
|
|
<label
|
|
x-ref="container"
|
|
|
|
@if($isDisabled())
|
|
disabled
|
|
@endif
|
|
|
|
@if(!$isDisabled() && !$isReadonly())
|
|
@click="focus()"
|
|
@endif
|
|
|
|
{{
|
|
$attributes->whereStartsWith('class')->class([
|
|
"flex items-center border border-gray-300 px-4 py-2 rounded-xl w-full min-h-[var(--size)] h-auto ps-2.5",
|
|
"join-item" => $prepend || $append,
|
|
"border-dashed" => $attributes->has("readonly") && $attributes->get("readonly") == true,
|
|
"!select-error" => $errorFieldName() && $errors->has($errorFieldName()) && !$omitError
|
|
])
|
|
}}
|
|
>
|
|
{{-- PREFIX --}}
|
|
@if($prefix)
|
|
<span class="label">{{ $prefix }}</span>
|
|
@endif
|
|
|
|
<div class="w-full py-0.5 min-h-3 content-center text-wrap">
|
|
|
|
{{-- SELECTED OPTIONS --}}
|
|
<span wire:key="selected-options-{{ $uuid }}">
|
|
@if($compact)
|
|
<div class="badge badge-soft">
|
|
<span class="font-black" x-text="selectedOptions.length"></span> {{ $compactText }}
|
|
</div>
|
|
@else
|
|
<template x-for="(option, index) in selectedOptions" :key="index">
|
|
<span class="choices-element cursor-pointer bg-gray-100 rounded-full px-4 py-0.5">
|
|
{{-- SELECTION SLOT --}}
|
|
@if($selection)
|
|
<span x-html="document.getElementById('selection-{{ $uuid . '-\' + option.'. $optionValue }}).innerHTML"></span>
|
|
@else
|
|
<span x-text="option?.{{ $optionLabel }}"></span>
|
|
@endif
|
|
|
|
@if(!$isDisabled() && !$isReadonly())
|
|
<lucide-x @click="toggle(option.{{ $optionValue }})" x-show="!isReadonly && !isDisabled && !isSingle" class="w-4 h-4 hover:text-error" />
|
|
@endif
|
|
</span>
|
|
</template>
|
|
@endif
|
|
</span>
|
|
|
|
{{-- PLACEHOLDER --}}
|
|
<span :class="(focused || !isSelectionEmpty) && 'hidden'" class="text-gray-400">
|
|
{{ $attributes->get('placeholder') }}
|
|
</span>
|
|
|
|
{{-- INPUT SEARCH --}}
|
|
<input
|
|
x-ref="searchInput"
|
|
@input="focus(); resize();"
|
|
@keydown.arrow-down.prevent="focus()"
|
|
:required="isRequired && isSelectionEmpty"
|
|
class="w-1 !inline-block outline-hidden"
|
|
|
|
{{ $attributes->whereStartsWith('@') }}
|
|
|
|
@if($isReadonly() || $isDisabled() || ! $searchable)
|
|
readonly
|
|
@else
|
|
@focus="focus()"
|
|
@endif
|
|
|
|
@if($isDisabled())
|
|
disabled
|
|
@endif
|
|
|
|
@if($searchable)
|
|
@keydown.debounce.{{ $debounce }}="search($el.value, $event)"
|
|
@endif
|
|
/>
|
|
</div>
|
|
|
|
{{-- CLEAR ICON --}}
|
|
@if($clearable && !$isReadonly() && !$isDisabled())
|
|
<x-lucide-x @click="reset()" x-show="!isSelectionEmpty" class="cursor-pointer w-4 h-4 opacity-40"/>
|
|
@endif
|
|
|
|
{{-- ICON RIGHT --}}
|
|
@if($iconRight)
|
|
<span class="pointer-events-none w-4 h-4 opacity-40">
|
|
@svg("lucide-{$iconRight}", 'w-4 h-4')
|
|
</span>
|
|
@else
|
|
<x-lucide-chevron-down class="w-4 h-4 opacity-40" />
|
|
@endif
|
|
|
|
{{-- SUFFIX --}}
|
|
@if($suffix)
|
|
<span class="label">{{ $suffix }}</span>
|
|
@endif
|
|
</label>
|
|
|
|
{{-- APPEND --}}
|
|
@if($append)
|
|
{{ $append }}
|
|
@endif
|
|
</div>
|
|
</label>
|
|
|
|
{{-- ERROR --}}
|
|
@if(!$omitError)
|
|
<x-shared.error :errorFieldName="$errorFieldName()" />
|
|
@endif
|
|
|
|
{{-- HINT --}}
|
|
@if($hint)
|
|
<div class="{{ $hintClass }}" x-classes="fieldset-label">{{ $hint }}</div>
|
|
@endif
|
|
</fieldset>
|
|
|
|
{{-- OPTIONS LIST --}}
|
|
<div x-cloak x-show="focused" class="relative" wire:key="options-list-main-{{ $uuid }}">
|
|
<div
|
|
wire:key="options-list-{{ $uuid }}"
|
|
class="{{ $height }} w-full absolute z-10 shadow-xl bg-gray-50 border border-gray-300 rounded-lg cursor-pointer overflow-y-auto"
|
|
x-anchor.bottom-start="$refs.container"
|
|
>
|
|
|
|
{{-- PROGRESS --}}
|
|
@if(!$noProgress)
|
|
<progress wire:loading wire:target="{{ preg_replace('/\((.*?)\)/', '', $searchFunction) }}" class="progress absolute top-0 h-0.5"></progress>
|
|
@endif
|
|
|
|
{{-- SELECT ALL --}}
|
|
@if($allowAll)
|
|
<div
|
|
wire:key="allow-all-{{ rand() }}"
|
|
class="font-bold border border-base-200 hover:bg-gray-200"
|
|
>
|
|
<div x-show="!isAllSelected" @click="selectAll()" class="p-3 underline decoration-wavy decoration-info">{{ $allowAllText }}</div>
|
|
<div x-show="isAllSelected" @click="reset()" class="p-3 underline decoration-wavy decoration-error">{{ $removeAllText }}</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- NO RESULTS --}}
|
|
<div
|
|
x-show="noResults"
|
|
wire:key="no-results-{{ rand() }}"
|
|
class="p-3 decoration-wavy decoration-warning underline font-bold border border-s-4 border-s-warning border-b-base-200"
|
|
>
|
|
{{ $noResultText }}
|
|
</div>
|
|
|
|
@forelse($options as $option)
|
|
<div
|
|
wire:key="option-{{ data_get($option, $optionValue) }}"
|
|
@click="toggle({{ $getOptionValue($option) }}, true)"
|
|
@keydown.enter="toggle({{ $getOptionValue($option) }}, true)"
|
|
:class="isActive({{ $getOptionValue($option) }}) && ''"
|
|
class="border border-gray-200 focus:bg-base-200 focus:outline-none"
|
|
tabindex="0"
|
|
>
|
|
{{-- ITEM SLOT --}}
|
|
@if($item)
|
|
{{ $item($option) }}
|
|
@else
|
|
<x-list-item :item="$option" :value="$optionLabel" :sub-value="$optionSubLabel" />
|
|
@endif
|
|
|
|
{{-- SELECTION SLOT --}}
|
|
@if($selection)
|
|
<span id="selection-{{ $uuid }}-{{ data_get($option, $optionValue) }}" class="hidden">
|
|
{{ $selection($option) }}
|
|
</span>
|
|
@endif
|
|
</div>
|
|
@empty
|
|
<div class="p-3 text-center text-sm">No results found.</div>
|
|
@endforelse
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
HTML;
|
|
}
|
|
}
|