Skip to content

Instantly share code, notes, and snippets.

@mallardduck
Created April 23, 2021 16:17
Show Gist options
  • Save mallardduck/4d48974d35679376278e535e6ba4f53d to your computer and use it in GitHub Desktop.
Save mallardduck/4d48974d35679376278e535e6ba4f53d to your computer and use it in GitHub Desktop.
SpatieTagsInput for filament
public static function form(Form $form)
{
return $form
->schema([
SpatieTagsInput::make('form_tags')
->relationship('tags', 'name')
]);
}
@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>
<?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