Instantly share code, notes, and snippets.
Created
April 23, 2021 16:17
-
Star
(2)
2
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save mallardduck/4d48974d35679376278e535e6ba4f53d to your computer and use it in GitHub Desktop.
SpatieTagsInput for filament
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static function form(Form $form) | |
{ | |
return $form | |
->schema([ | |
SpatieTagsInput::make('form_tags') | |
->relationship('tags', 'name') | |
]); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@pushonce('filament-scripts:tags-input-component') | |
<script> | |
function spatieTagsInput(config) { | |
console.log( | |
config | |
); | |
return { | |
hasError: false, | |
newTag: '', | |
separator: config.separator, | |
options: config.initialOptions ?? [], | |
tags: config.initialDisplayValue ?? [], | |
value: config.value, | |
// props for search | |
open: false, | |
loading: false, | |
focusedOptionIndex: null, | |
search: '', | |
emptyOptionsMessage: config.emptyOptionsMessage, | |
noSearchResultsMessage: config.noSearchResultsMessage, | |
closeListbox: function () { | |
this.open = false | |
this.focusedOptionIndex = null | |
this.search = '' | |
}, | |
evaluatePosition: function () { | |
let availableHeight = window.innerHeight - this.$refs.button.offsetHeight | |
let element = this.$refs.button | |
while (element) { | |
availableHeight -= element.offsetTop | |
element = element.offsetParent | |
} | |
if (this.$refs.listbox.offsetHeight <= availableHeight) { | |
this.$refs.listbox.style.bottom = 'auto' | |
return | |
} | |
this.$refs.listbox.style.bottom = `${this.$refs.button.offsetHeight}px` | |
}, | |
focusNextOption: function () { | |
if (this.focusedOptionIndex === null) { | |
this.focusedOptionIndex = Object.keys(this.options).length - 1 | |
return | |
} | |
if (this.focusedOptionIndex + 1 >= Object.keys(this.options).length) return | |
this.focusedOptionIndex++ | |
this.$refs.listboxOptionsList.children[this.focusedOptionIndex].scrollIntoView({ | |
block: 'center', | |
}) | |
}, | |
focusPreviousOption: function () { | |
if (this.focusedOptionIndex === null) { | |
this.focusedOptionIndex = 0 | |
return | |
} | |
if (this.focusedOptionIndex <= 0) return | |
this.focusedOptionIndex-- | |
this.$refs.listboxOptionsList.children[this.focusedOptionIndex].scrollIntoView({ | |
block: 'center', | |
}) | |
}, | |
createTag: function () { | |
this.newTag = this.newTag.trim() | |
if (this.newTag === '' || this.tags.includes(this.newTag)) { | |
this.hasError = true | |
return | |
} | |
this.tags.push(this.newTag) | |
this.newTag = '' | |
}, | |
deleteTag: function (tagToDelete) { | |
this.tags = this.tags.filter((tag) => tag !== tagToDelete) | |
}, | |
init: function () { | |
this.$watch('newTag', () => this.hasError = false) | |
this.$watch('tags', () => { | |
this.value = JSON.stringify(Object.values(this.tags)); | |
}) | |
this.$watch('search', () => { | |
if (this.search === '' || this.search === null) { | |
this.options = config.initialOptions | |
this.filterSelectedTags(); | |
this.focusedOptionIndex = 0 | |
return | |
} | |
if (Object.keys(config.initialOptions).length) { | |
this.options = {} | |
let search = this.search.trim().toLowerCase() | |
for (let key in config.initialOptions) { | |
if (config.initialOptions[key].trim().toLowerCase().includes(search)) { | |
this.options[key] = config.initialOptions[key] | |
} | |
} | |
this.focusedOptionIndex = 0 | |
} else { | |
this.loading = true | |
this.$wire.getSelectFieldOptionSearchResults(this.name, this.search).then((options) => { | |
this.options = options | |
this.focusedOptionIndex = 0 | |
this.loading = false | |
}) | |
} | |
}) | |
}, | |
filterSelectedTags: function () { | |
if (this.tags.length > 0) { | |
let currentTagNames = this.tags; | |
this.options = this.options.filter(function (value) { | |
return ! currentTagNames.includes(value); | |
}); | |
} | |
}, | |
openListbox: function () { | |
this.filterSelectedTags(); | |
this.focusedOptionIndex = Object.keys(this.options).indexOf(this.value) | |
if (this.focusedOptionIndex < 0) this.focusedOptionIndex = 0 | |
this.open = true | |
this.$nextTick(() => { | |
this.$refs.search.focus() | |
this.evaluatePosition() | |
this.$refs.listboxOptionsList.children[this.focusedOptionIndex].scrollIntoView({ | |
block: 'center' | |
}) | |
}) | |
}, | |
selectOption: function (index = null) { | |
this.newTag = Object.values(this.options)[index ?? this.focusedOptionIndex] | |
if (this.newTag === '' || this.tags.includes(this.newTag)) { | |
this.hasError = true | |
return | |
} | |
this.tags.push(this.newTag) | |
this.newTag = '' | |
this.closeListbox() | |
}, | |
} | |
} | |
</script> | |
@endpushonce | |
<x-forms::field-group | |
:column-span="$formComponent->getColumnSpan()" | |
:error-key="$formComponent->getName()" | |
:for="$formComponent->getId()" | |
:help-message="$formComponent->getHelpMessage()" | |
:hint="$formComponent->getHint()" | |
:label="$formComponent->getLabel()" | |
:required="$formComponent->isRequired()" | |
> | |
<div | |
x-data="spatieTagsInput({ | |
emptyOptionsMessage: '{{ __($formComponent->getEmptyOptionsMessage()) }}', | |
noSearchResultsMessage: '{{ __($formComponent->getNoSearchResultsMessage()) }}', | |
initialDisplayValue: {{ data_get($this, $formComponent->getName()) !== null ? json_encode($formComponent->getDisplayValue(data_get($this, $formComponent->getName()))): 'null' }}, | |
initialOptions: {{ json_encode($formComponent->getOptions()) }}, | |
@if (Str::of($formComponent->getBindingAttribute())->startsWith('wire:model')) | |
value: @entangle($formComponent->getName()){{ Str::of($formComponent->getBindingAttribute())->after('wire:model') }}, | |
@endif | |
})" | |
x-init="init()" | |
{!! $formComponent->getId() ? "id=\"{$formComponent->getId()}\"" : null !!} | |
{!! Filament\format_attributes($formComponent->getExtraAttributes()) !!} | |
> | |
@unless (Str::of($formComponent->getBindingAttribute())->startsWith(['wire:model', 'x-model'])) | |
<input | |
x-model="value" | |
{!! $formComponent->getName() ? "{$formComponent->getBindingAttribute()}=\"{$formComponent->getName()}\"" : null !!} | |
type="hidden" | |
/> | |
@endif | |
<div | |
x-ref="button" | |
x-show="tags.length || {{ $formComponent->isDisabled() ? 'false' : 'true' }}" | |
class="rounded shadow-sm border overflow-hidden {{ $errors->has($formComponent->getName()) ? 'border-danger-600 motion-safe:animate-shake' : 'border-gray-300' }}" | |
> | |
@unless ($formComponent->isDisabled()) | |
<input | |
x-ref="search" | |
x-on:click="openListbox()" | |
x-on:click.away="closeListbox()" | |
x-on:blur="closeListbox()" | |
x-on:keydown.escape.stop="closeListbox()" | |
x-model.debounce.500="search" | |
x-on:keydown.enter.stop.prevent="selectOption()" | |
x-on:keydown.arrow-up.stop.prevent="focusPreviousOption()" | |
x-on:keydown.arrow-down.stop.prevent="focusNextOption()" | |
type="search" | |
autocomplete="off" | |
{!! $formComponent->getPlaceholder() ? 'placeholder="' . __($formComponent->getPlaceholder()) . '"' : null !!} | |
class="block w-full placeholder-gray-400 focus:placeholder-gray-500 placeholder-opacity-100 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50 border-0" | |
x-bind:class="{ 'text-danger-700': hasError }" | |
/> | |
@endunless | |
@unless($formComponent->isDisabled()) | |
<div | |
x-ref="listbox" | |
x-show="open" | |
x-transition:leave="transition ease-in duration-100" | |
x-transition:leave-start="opacity-100" | |
x-transition:leave-end="opacity-0" | |
role="listbox" | |
x-bind:aria-activedescendant="focusedOptionIndex ? '{{ $formComponent->getName() }}' + 'Option' + focusedOptionIndex : null" | |
tabindex="-1" | |
x-cloak | |
class="absolute z-50 w-full my-1 bg-white border border-gray-300 rounded shadow-sm" | |
> | |
<ul | |
x-ref="listboxOptionsList" | |
class="py-1 overflow-auto text-base leading-6 rounded shadow-sm max-h-60 focus:outline-none" | |
> | |
<template x-for="(key, index) in Object.keys(options)" :key="index"> | |
<li | |
x-bind:id="'{{ $formComponent->getName() }}' + 'Option' + focusedOptionIndex" | |
x-on:click="selectOption(index)" | |
x-on:mouseenter="focusedOptionIndex = index" | |
x-on:mouseleave="focusedOptionIndex = null" | |
role="option" | |
x-bind:aria-selected="focusedOptionIndex === index" | |
x-bind:class="{ | |
'text-white bg-blue-600': index === focusedOptionIndex, | |
'text-gray-900': index !== focusedOptionIndex, | |
}" | |
class="relative py-2 pl-3 text-gray-900 cursor-default select-none pr-9" | |
> | |
<span | |
x-text="Object.values(options)[index]" | |
x-bind:class="{ | |
'font-medium': index === focusedOptionIndex, | |
'font-normal': index !== focusedOptionIndex, | |
}" | |
class="font-normal truncate" | |
></span> | |
<span | |
x-show="key === value" | |
x-bind:class="{ | |
'text-white': index === focusedOptionIndex, | |
'text-blue-600': index !== focusedOptionIndex, | |
}" | |
class="absolute inset-y-0 right-0 flex items-center pr-4 text-blue-600" | |
> | |
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor"> | |
<path fill-rule="evenodd" | |
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" | |
clip-rule="evenodd" /> | |
</svg> | |
</span> | |
</li> | |
</template> | |
<div | |
x-show="! Object.keys(options).length" | |
x-text="! search || loading ? emptyOptionsMessage : noSearchResultsMessage" | |
class="px-3 py-2 text-sm text-gray-900 cursor-default select-none" | |
></div> | |
</ul> | |
</div> | |
@endunless | |
<div | |
x-show="tags.length" | |
class="bg-white space-x-1 relative w-full pl-3 pr-10 py-2 text-left {{ $formComponent->isDisabled() ? 'text-gray-500' : 'border-t' }} {{ $errors->has($formComponent->getName()) ? 'border-danger-600' : 'border-gray-300' }}" | |
> | |
<template class="inline" x-for="tag in tags" x-bind:key="tag"> | |
<button | |
@unless($formComponent->isDisabled()) | |
x-on:click="deleteTag(tag)" | |
@endunless | |
type="button" | |
class="my-1 truncate max-w-full inline-flex space-x-2 items-center font-mono text-xs py-1 px-2 border border-gray-300 bg-gray-100 text-gray-800 rounded shadow-sm inline-block relative @unless($formComponent->isDisabled()) cursor-pointer transition duration-200 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 hover:bg-gray-200 transition-colors duration-200 @else cursor-default @endunless" | |
> | |
<span x-text="tag"></span> | |
@unless($formComponent->isDisabled()) | |
<x-heroicon-s-x class="w-3 h-3 text-gray-500" /> | |
@endunless | |
</button> | |
</template> | |
</div> | |
</div> | |
</div> | |
</x-forms::field-group> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Blog\View\Components; | |
use Filament\Forms\Components\TagsInput; | |
use Filament\Resources\Forms\Components\Concerns\InteractsWithResource; | |
use Illuminate\Support\Str; | |
class SpatieTagsInput extends TagsInput | |
{ | |
use InteractsWithResource; | |
protected $emptyOptionsMessage = 'forms::fields.select.emptyOptionsMessage'; | |
protected $noSearchResultsMessage = 'forms::fields.select.noSearchResultsMessage'; | |
protected $getOptions; | |
protected $options; | |
protected $shouldPreload = false; | |
protected $isPreloaded = false; | |
protected $relationshipName; | |
protected $view = 'components.spatie-tags-input'; | |
public function getEmptyOptionsMessage() | |
{ | |
return $this->emptyOptionsMessage; | |
} | |
public function getNoSearchResultsMessage() | |
{ | |
return $this->noSearchResultsMessage; | |
} | |
public function getLabel() | |
{ | |
if ($this->label === null) { | |
return (string) Str::of($this->getRelationshipName()) | |
->before('.') | |
->kebab() | |
->replace(['-', '_'], ' ') | |
->ucfirst(); | |
} | |
return parent::getLabel(); | |
} | |
public function getModel() | |
{ | |
return $this->getForm()->getModel(); | |
} | |
public function getRelationshipName() | |
{ | |
return $this->relationshipName; | |
} | |
public function getRelationship() | |
{ | |
$model = $this->getModel(); | |
return (new $model())->{$this->getRelationshipName()}(); | |
} | |
public function shouldPreload(): bool | |
{ | |
return $this->shouldPreload; | |
} | |
public function isPreloaded(): bool | |
{ | |
return $this->shouldPreload && $this->isPreloaded; | |
} | |
public function preload() | |
{ | |
$this->configure(function () { | |
$this->shouldPreload = true; | |
}); | |
return $this; | |
} | |
public function relationship(string $relationshipName) | |
{ | |
$this->configure(function () use ($relationshipName) { | |
$this->relationshipName = $relationshipName; | |
$this->getDisplayValueUsing(function ($value) { | |
$tags = $value->pluck('name'); | |
return 0 < $tags->count() ? $tags : null; | |
}); | |
$this->getOptionsUsing(function () { | |
$relationship = $this->getRelationship(); | |
$query = $relationship->getRelated(); | |
return $query->get()->map(fn($item) => $item->name); | |
}); | |
}); | |
return $this; | |
} | |
public function options($options) | |
{ | |
$this->configure(function () use ($options) { | |
$this->options = $options; | |
}); | |
return $this; | |
} | |
public function getOptions() | |
{ | |
if ($callback = $this->getOptions) { | |
if (!$this->shouldPreload()) { | |
return $callback(); | |
} | |
if (!$this->isPreloaded()) { | |
$this->options = $callback(); | |
} | |
} | |
return $this->options; | |
} | |
public function getOptionsUsing($callback) | |
{ | |
$this->configure(function () use ($callback) { | |
$this->getOptions = $callback; | |
}); | |
return $this; | |
} | |
public function getDisplayValue($value) | |
{ | |
$callback = $this->getDisplayValue; | |
return $callback($value); | |
} | |
public function getDisplayValueUsing($callback) | |
{ | |
$this->configure(function () use ($callback) { | |
$this->getDisplayValue = $callback; | |
}); | |
return $this; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment