├── resources ├── dist │ ├── .gitkeep │ ├── filament-icon-picker-pro.js │ └── components │ │ └── icon-picker-component.js ├── views │ ├── .gitkeep │ ├── components │ │ ├── searching.blade.php │ │ ├── no-results-found.blade.php │ │ └── search.blade.php │ ├── search-results │ │ ├── grid.blade.php │ │ ├── list.blade.php │ │ └── icons.blade.php │ └── forms │ │ └── components │ │ └── icon-picker.blade.php ├── css │ └── index.css ├── lang │ ├── en │ │ ├── icon-picker.php │ │ ├── validation.php │ │ └── actions.php │ ├── de │ │ ├── icon-picker.php │ │ ├── validation.php │ │ └── actions.php │ └── cs │ │ ├── icon-picker.php │ │ ├── validation.php │ │ └── actions.php └── js │ └── components │ └── icon-picker-component.js ├── postcss.config.cjs ├── src ├── Testing │ └── TestsIconPicker.php ├── Icons │ ├── Facades │ │ └── IconManager.php │ ├── Icon.php │ ├── IconManager.php │ └── IconSet.php ├── Forms │ ├── Components │ │ ├── Concerns │ │ │ ├── CanUseDropdown.php │ │ │ ├── CanCloseOnSelect.php │ │ │ ├── CanBeScopedToModel.php │ │ │ ├── HasSets.php │ │ │ ├── CanUploadCustomIcons.php │ │ │ └── HasSearchResultsView.php │ │ └── IconPicker.php │ └── Concerns │ │ └── CanBeCacheable.php ├── IconPickerPlugin.php ├── Validation │ ├── VerifyIcon.php │ └── VerifyIconScope.php ├── IconPickerServiceProvider.php ├── Actions │ └── UploadCustomIcon.php └── Tables │ └── Columns │ └── IconColumn.php ├── LICENSE.md ├── bin └── build.js ├── .releaserc.json ├── composer.json └── README.md /resources/dist/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/css/index.css: -------------------------------------------------------------------------------- 1 | @import '../../vendor/filament/filament/resources/css/theme.css'; 2 | -------------------------------------------------------------------------------- /resources/lang/en/icon-picker.php: -------------------------------------------------------------------------------- 1 | 'No icon selected', 5 | 'all-icons' => 'All icons', 6 | ]; 7 | -------------------------------------------------------------------------------- /resources/lang/de/icon-picker.php: -------------------------------------------------------------------------------- 1 | 'Kein Icon ausgewählt', 5 | 'all-icons' => 'Alle Icons', 6 | ]; 7 | -------------------------------------------------------------------------------- /resources/lang/cs/icon-picker.php: -------------------------------------------------------------------------------- 1 | 'Není vybrána žádná ikona', 5 | 'all-icons' => 'Všechny ikony', 6 | ]; 7 | -------------------------------------------------------------------------------- /resources/lang/cs/validation.php: -------------------------------------------------------------------------------- 1 | 'Ikona s tímto názvem již existuje.', 5 | 'icon-does-not-exist' => 'Tato ikona neexistuje.', 6 | ]; 7 | -------------------------------------------------------------------------------- /resources/lang/en/validation.php: -------------------------------------------------------------------------------- 1 | 'An icon with this name already exists.', 5 | 'icon-does-not-exist' => 'This icon does not exist.', 6 | ]; 7 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | "tailwindcss/nesting": {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /resources/dist/filament-icon-picker-pro.js: -------------------------------------------------------------------------------- 1 | //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFtdLAogICJzb3VyY2VzQ29udGVudCI6IFtdLAogICJtYXBwaW5ncyI6ICIiLAogICJuYW1lcyI6IFtdCn0K 2 | -------------------------------------------------------------------------------- /resources/lang/de/validation.php: -------------------------------------------------------------------------------- 1 | 'Ein Icon mit dieser Bezeichnung existiert bereits.', 5 | 'icon-does-not-exist' => 'Dieser Ikon existiert nicht.', 6 | ]; 7 | -------------------------------------------------------------------------------- /src/Testing/TestsIconPicker.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'label' => 'Upload custom icon', 6 | 7 | 'schema' => [ 8 | 'file' => [ 9 | 'label' => 'Icon', 10 | ], 11 | 12 | 'label' => [ 13 | 'label' => 'Label', 14 | ], 15 | ], 16 | ], 17 | ]; 18 | -------------------------------------------------------------------------------- /resources/lang/cs/actions.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'label' => 'Nahrát vlastní ikonu', 6 | 7 | 'schema' => [ 8 | 'file' => [ 9 | 'label' => 'Ikona', 10 | ], 11 | 12 | 'label' => [ 13 | 'label' => 'Název', 14 | ], 15 | ], 16 | ], 17 | ]; 18 | -------------------------------------------------------------------------------- /resources/lang/de/actions.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'label' => 'Eigenes Icon hochladen', 6 | 7 | 'schema' => [ 8 | 'file' => [ 9 | 'label' => 'Icon', 10 | ], 11 | 12 | 'label' => [ 13 | 'label' => 'Bezeichnung', 14 | ], 15 | ], 16 | ], 17 | ]; 18 | -------------------------------------------------------------------------------- /src/Icons/Facades/IconManager.php: -------------------------------------------------------------------------------- 1 | dropdown = $condition; 14 | 15 | return $this; 16 | } 17 | 18 | public function isDropdown(): bool 19 | { 20 | return $this->evaluate($this->dropdown); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /resources/views/components/searching.blade.php: -------------------------------------------------------------------------------- 1 | @php 2 | use function Filament\Support\generate_loading_indicator_html; 3 | @endphp 4 | 5 | @props([ 6 | 'field' 7 | ]) 8 | 9 | 10 |
11 | {{generate_loading_indicator_html()}} 12 | {{$field->getSearchingMessage()}} 13 |
14 |
15 | -------------------------------------------------------------------------------- /src/Forms/Components/Concerns/CanCloseOnSelect.php: -------------------------------------------------------------------------------- 1 | closeOnSelect = $condition; 14 | 15 | return $this; 16 | } 17 | 18 | public function shouldCloseOnSelect(): bool 19 | { 20 | return $this->evaluate($this->closeOnSelect); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Icons/Icon.php: -------------------------------------------------------------------------------- 1 | label = str($this->name)->afterLast('.')->headline()->lower()->ucfirst(); 17 | $this->custom = $this->set->custom; 18 | } 19 | 20 | public function getSet(): IconSet 21 | { 22 | return $this->set; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Forms/Components/Concerns/CanBeScopedToModel.php: -------------------------------------------------------------------------------- 1 | scopedTo = $record; 15 | 16 | return $this; 17 | } 18 | 19 | public function getScopedTo(): ?Model 20 | { 21 | return $this->evaluate($this->scopedTo); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/IconPickerPlugin.php: -------------------------------------------------------------------------------- 1 | getId()); 34 | 35 | return $plugin; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Validation/VerifyIcon.php: -------------------------------------------------------------------------------- 1 | iconFactory = app(IconFactory::class); 18 | } 19 | 20 | public function validate(string $attribute, mixed $value, Closure $fail): void 21 | { 22 | // Check if icon exists 23 | if (! IconManager::getIcon($value)) { 24 | $fail('Icon does not exist.'); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /resources/views/components/no-results-found.blade.php: -------------------------------------------------------------------------------- 1 | @php 2 | use function Filament\Support\generate_loading_indicator_html; 3 | @endphp 4 | 5 | @props([ 6 | 'field' 7 | ]) 8 | 9 | 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Guava 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Forms/Components/Concerns/HasSets.php: -------------------------------------------------------------------------------- 1 | sets = $sets; 17 | 18 | return $this; 19 | } 20 | 21 | public function getSets(): ?array 22 | { 23 | return $this->evaluate($this->sets); 24 | } 25 | 26 | public function getAllowedSets(): Collection 27 | { 28 | return IconManager::getSets() 29 | ->when( 30 | ! $this->isCustomIconsUploadEnabled(), 31 | fn (Collection $items) => $items->filter(fn (IconSet $set) => ! $set->custom) 32 | ) 33 | ->when( 34 | $allowedSets = $this->getSets(), 35 | fn (Collection $items) => $items->filter(fn (IconSet $set) => in_array($set->getId(), $allowedSets)) 36 | ) 37 | ; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /resources/views/search-results/grid.blade.php: -------------------------------------------------------------------------------- 1 | @php 2 | use function Filament\Support\generate_loading_indicator_html; 3 | @endphp 4 | 5 |
6 | 23 |
24 |
25 | -------------------------------------------------------------------------------- /src/Validation/VerifyIconScope.php: -------------------------------------------------------------------------------- 1 | before('-')->toString(); 18 | $scope = str($value)->after('-')->before('.')->toString(); 19 | 20 | // TODO: replace magic value 21 | if ($prefix !== '_gfic_icons') { 22 | return; 23 | } 24 | 25 | // Custom icon without scope - should not be possible 26 | if (empty($scope)) { 27 | $fail('Scope missing for custom icon.'); 28 | } 29 | if ($this->getScopeId($this->scopedTo) !== $scope) { 30 | $fail('Unauthorized icon scope.'); 31 | } 32 | } 33 | 34 | private function getScopeId(?Model $model): string 35 | { 36 | if ($model === null) { 37 | return 'unscoped'; 38 | } 39 | 40 | return md5("{$model->getMorphClass()}::{$model->getKey()}"); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /resources/views/search-results/list.blade.php: -------------------------------------------------------------------------------- 1 | @php 2 | use function Filament\Support\generate_loading_indicator_html; 3 | @endphp 4 | 5 |
6 | 26 |
27 |
28 | -------------------------------------------------------------------------------- /resources/views/components/search.blade.php: -------------------------------------------------------------------------------- 1 | @php 2 | use function Filament\Support\generate_loading_indicator_html; 3 | @endphp 4 | 5 | @props([ 6 | 'field', 7 | 'sets', 8 | 'searchPrompt', 9 | 'isDropdown' => false, 10 | ]) 11 | 12 | $isDropdown, 16 | ]) 17 | x-bind="dropdownMenu" 18 | x-cloak 19 | > 20 |
21 | 22 | 23 | 24 | @foreach($sets as $set) 25 | 26 | @endforeach 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {{ $field->getSearchResultsViewComponent() }} 39 |
40 |
41 | -------------------------------------------------------------------------------- /src/Forms/Components/Concerns/CanUploadCustomIcons.php: -------------------------------------------------------------------------------- 1 | customIconsUploadEnabled = $condition; 16 | 17 | return $this; 18 | } 19 | 20 | public function isCustomIconsUploadEnabled(): ?bool 21 | { 22 | return $this->evaluate($this->customIconsUploadEnabled); 23 | } 24 | 25 | public function getCustomIconsUploadAction(): UploadCustomIcon 26 | { 27 | return UploadCustomIcon::make() 28 | ->disabled($this->isDisabled()) 29 | ; 30 | } 31 | 32 | public function callAfterCustomIconUploaded(): void 33 | { 34 | if ($state = $this->getState()) { 35 | if ($icon = $this->getIconsJs()->first(fn (Icon $icon) => $icon->id === $state)) { 36 | $this->getLivewire()->dispatch( 37 | "custom-icon-uploaded::{$this->getKey()}", 38 | id: $icon->id, 39 | label: $icon->label, 40 | set: $icon->getSet()->getId() 41 | ); 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Forms/Concerns/CanBeCacheable.php: -------------------------------------------------------------------------------- 1 | cacheable = $cacheable; 19 | 20 | return $this; 21 | } 22 | 23 | public function getCacheable(): bool 24 | { 25 | $cacheable = $this->cacheable ?? config('icon-picker.cache.enabled', true); 26 | 27 | return $this->evaluate($cacheable); 28 | } 29 | 30 | public function cacheDuration(int | DateInterval | DateTimeInterface | Closure $cacheDuration): static 31 | { 32 | $this->cacheDuration = $cacheDuration; 33 | 34 | return $this; 35 | } 36 | 37 | public function getCacheDuration() 38 | { 39 | $duration = $this->cacheDuration ?? now()->add(config('icon-picker.cache.duration', '7 days')); 40 | 41 | return $this->evaluate($duration); 42 | } 43 | 44 | protected function tryCache(string $key, Closure $callback) 45 | { 46 | if (! $this->getCacheable()) { 47 | return $callback->call($this); 48 | } 49 | 50 | return Cache::remember($key, $this->getCacheDuration(), $callback); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /bin/build.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild' 2 | 3 | const isDev = process.argv.includes('--dev') 4 | 5 | async function compile(options) { 6 | const context = await esbuild.context(options) 7 | 8 | if (isDev) { 9 | await context.watch() 10 | } else { 11 | await context.rebuild() 12 | await context.dispose() 13 | } 14 | } 15 | 16 | const defaultOptions = { 17 | define: { 18 | 'process.env.NODE_ENV': isDev ? `'development'` : `'production'`, 19 | }, 20 | bundle: true, 21 | mainFields: ['module', 'main'], 22 | platform: 'neutral', 23 | sourcemap: isDev ? 'inline' : false, 24 | sourcesContent: isDev, 25 | treeShaking: true, 26 | target: ['es2020'], 27 | minify: !isDev, 28 | plugins: [{ 29 | name: 'watchPlugin', 30 | setup: function (build) { 31 | build.onStart(() => { 32 | console.log(`Build started at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`) 33 | }) 34 | 35 | build.onEnd((result) => { 36 | if (result.errors.length > 0) { 37 | console.log(`Build failed at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`, result.errors) 38 | } else { 39 | console.log(`Build finished at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`) 40 | } 41 | }) 42 | } 43 | }], 44 | } 45 | 46 | compile({ 47 | ...defaultOptions, 48 | entryPoints: ['./resources/js/components/icon-picker-component.js'], 49 | outfile: './resources/dist/components/icon-picker-component.js', 50 | }) 51 | -------------------------------------------------------------------------------- /resources/views/search-results/icons.blade.php: -------------------------------------------------------------------------------- 1 | @php 2 | use Filament\Support\Icons\Heroicon; 3 | use function Filament\Support\generate_icon_html; 4 | use function Filament\Support\generate_loading_indicator_html; 5 | @endphp 6 | 7 | @props([ 8 | 'withTooltips', 9 | ]) 10 | 11 |
13 | 41 |
42 |
43 | -------------------------------------------------------------------------------- /src/Forms/Components/Concerns/HasSearchResultsView.php: -------------------------------------------------------------------------------- 1 | searchResultsView('guava-icon-picker::search-results.grid'); 17 | } 18 | 19 | public function listSearchResults(): static 20 | { 21 | return $this->searchResultsView('guava-icon-picker::search-results.list'); 22 | } 23 | 24 | public function iconsSearchResults(bool $withTooltips = true): static 25 | { 26 | return $this->searchResultsView('guava-icon-picker::search-results.icons', [ 27 | 'withTooltips' => $withTooltips, 28 | ]); 29 | } 30 | 31 | public function searchResultsView(Closure | string | View $view, Closure | array | null $viewData = null): static 32 | { 33 | $this->searchResultsView = $view; 34 | 35 | if ($viewData) { 36 | $this->searchResultsViewData($viewData); 37 | } 38 | 39 | return $this; 40 | } 41 | 42 | public function getSearchResultsView(): string 43 | { 44 | return $this->evaluate($this->searchResultsView); 45 | } 46 | 47 | public function searchResultsViewData(Closure | array $viewData): static 48 | { 49 | $this->searchResultsViewData = $viewData; 50 | 51 | return $this; 52 | } 53 | 54 | public function getSearchResultsViewData(): array 55 | { 56 | $viewData = $this->evaluate($this->searchResultsViewData) ?? []; 57 | 58 | return [ 59 | ...$viewData, 60 | ...$this->getDefaultSearchResultsViewData(), 61 | ]; 62 | } 63 | 64 | public function getSearchResultsViewComponent(): View 65 | { 66 | return view( 67 | $this->getSearchResultsView(), 68 | $this->getSearchResultsViewData() 69 | ); 70 | } 71 | 72 | protected function getDefaultSearchResultsViewData(): array 73 | { 74 | return [ 75 | 'field' => $this, 76 | ]; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Icons/IconManager.php: -------------------------------------------------------------------------------- 1 | factory->all()) 18 | ->map(static fn (array $configuration, string $id) => IconSet::createFromArray($configuration, $id)) 19 | ; 20 | } 21 | 22 | public function getIcons(null | string | IconSet $set = null, ?Model $scope = null, bool $checkScope = true): Collection 23 | { 24 | if ($set instanceof IconSet) { 25 | $set = $set->getId(); 26 | } 27 | 28 | return $this->getSets() 29 | ->when( 30 | $set, 31 | fn (Collection $sets) => $sets->filter(fn (IconSet $iconSet) => $iconSet->getId() === $set) 32 | ) 33 | ->map(fn (IconSet $is) => $is->getIcons($scope, $checkScope)) 34 | ->collapse() 35 | ; 36 | } 37 | 38 | public function getSetByPrefix(string $prefix): ?IconSet 39 | { 40 | return collect($this->getSets())->where(fn (IconSet $set) => $set->getPrefix() === $prefix)->first(); 41 | } 42 | 43 | public function getSetFromIcon(string $id): ?IconSet 44 | { 45 | $prefix = str($id)->before('-'); 46 | 47 | return $this->getSetByPrefix($prefix); 48 | } 49 | 50 | public function getIcon(?string $id, bool $checkScope = false): ?Icon 51 | { 52 | if ($id === null) { 53 | return null; 54 | } 55 | 56 | if ($checkScope) { 57 | return $this->getIcons(checkScope: $checkScope)->first(fn (Icon $icon) => $icon->id === $id); 58 | } 59 | 60 | /** @var IconSet[] $sets */ 61 | $sets = $this->getSets(); 62 | 63 | foreach ($sets as $set) { 64 | if (str($id)->startsWith($set->getPrefix())) { 65 | return new Icon( 66 | $id, 67 | str($id)->headline()->lower()->ucfirst(), 68 | $set 69 | ); 70 | } 71 | } 72 | 73 | return null; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "@semantic-release/commit-analyzer", 5 | { 6 | "preset": "conventionalcommits", 7 | "releaseRules": [ 8 | { 9 | "scope": "no-release", 10 | "release": false 11 | } 12 | ] 13 | } 14 | ], 15 | [ 16 | "@semantic-release/release-notes-generator", 17 | { 18 | "preset": "conventionalcommits", 19 | "presetConfig": { 20 | "types": [ 21 | { 22 | "type": "feat", 23 | "section": "Features" 24 | }, 25 | { 26 | "type": "fix", 27 | "section": "Bug Fixes" 28 | }, 29 | { 30 | "type": "refactor", 31 | "section": "Refactor" 32 | }, 33 | { 34 | "type": "docs", 35 | "section": "Documentation" 36 | }, 37 | { 38 | "type": "chore", 39 | "section": "Chore" 40 | }, 41 | { 42 | "type": "style", 43 | "section": "Style" 44 | }, 45 | { 46 | "type": "perf", 47 | "section": "Performance" 48 | }, 49 | { 50 | "type": "test", 51 | "section": "Tests" 52 | } 53 | ] 54 | } 55 | } 56 | ], 57 | "@semantic-release/github" 58 | ], 59 | "branches": [ 60 | "+([0-9])?(.{+([0-9]),x}).x", 61 | "main", 62 | "master", 63 | { 64 | "name": "beta", 65 | "prerelease": true 66 | }, 67 | { 68 | "name": "alpha", 69 | "prerelease": true 70 | } 71 | ], 72 | "tagFormat": "${version}" 73 | } 74 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "guava/filament-icon-picker", 3 | "description": "A filament plugin that adds an icon picker field.", 4 | "type": "library", 5 | "license": "MIT", 6 | "keywords": [ 7 | "Guava", 8 | "laravel", 9 | "filament-icon-picker" 10 | ], 11 | "homepage": "https://github.com/GuavaCZ/filament-icon-picker", 12 | "support": { 13 | "issues": "https://github.com/guavaCZ/filament-icon-picker/issues", 14 | "source": "https://github.com/guavaCZ/filament-icon-picker" 15 | }, 16 | "authors": [ 17 | { 18 | "name": "Lukas Frey", 19 | "email": "lukas.frey@guava.cz", 20 | "role": "Developer" 21 | } 22 | ], 23 | "require": { 24 | "php": "^8.2", 25 | "blade-ui-kit/blade-icons": "^1.8", 26 | "filament/filament": "^4.0", 27 | "spatie/laravel-package-tools": "^1.15.0" 28 | }, 29 | "require-dev": { 30 | "laravel/pint": "^1.0", 31 | "nunomaduro/collision": "^8.0", 32 | "nunomaduro/larastan": "^3.0", 33 | "orchestra/testbench": "^10.0", 34 | "pestphp/pest": "^3.1", 35 | "pestphp/pest-plugin-arch": "^3.0", 36 | "pestphp/pest-plugin-laravel": "^3.0", 37 | "phpstan/extension-installer": "^1.1", 38 | "phpstan/phpstan-deprecation-rules": "^2.0", 39 | "phpstan/phpstan-phpunit": "2.0" 40 | }, 41 | "autoload": { 42 | "psr-4": { 43 | "Guava\\IconPicker\\": "src/", 44 | "Guava\\IconPicker\\Database\\Factories\\": "database/factories/" 45 | } 46 | }, 47 | "autoload-dev": { 48 | "psr-4": { 49 | "Guava\\IconPicker\\Tests\\": "tests/" 50 | } 51 | }, 52 | "scripts": { 53 | "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", 54 | "analyse": "vendor/bin/phpstan analyse", 55 | "test": "vendor/bin/pest", 56 | "test-coverage": "vendor/bin/pest --coverage", 57 | "format": "vendor/bin/pint" 58 | }, 59 | "config": { 60 | "sort-packages": true, 61 | "allow-plugins": { 62 | "pestphp/pest-plugin": true, 63 | "phpstan/extension-installer": true 64 | } 65 | }, 66 | "extra": { 67 | "laravel": { 68 | "providers": [ 69 | "Guava\\IconPicker\\IconPickerServiceProvider" 70 | ], 71 | "aliases": { 72 | "IconPicker": "IconManager" 73 | } 74 | } 75 | }, 76 | "minimum-stability": "dev", 77 | "prefer-stable": true 78 | } 79 | -------------------------------------------------------------------------------- /src/IconPickerServiceProvider.php: -------------------------------------------------------------------------------- 1 | name(static::$name); 30 | 31 | if (file_exists($package->basePath('/../resources/lang'))) { 32 | $package->hasTranslations(); 33 | } 34 | 35 | if (file_exists($package->basePath('/../resources/views'))) { 36 | $package->hasViews(static::$viewNamespace); 37 | } 38 | } 39 | 40 | public function packageRegistered(): void 41 | { 42 | $this->callAfterResolving(IconFactory::class, function (IconFactory $factory) { 43 | Storage::disk('public')->makeDirectory('icon-picker-icons'); 44 | 45 | $factory->add('icon-picker-icons', [ 46 | 'path' => 'icon-picker-icons', 47 | 'disk' => 'public', 48 | 'prefix' => '_gfic_icons', 49 | ]); 50 | }); 51 | } 52 | 53 | public function packageBooted(): void 54 | { 55 | // Asset Registration 56 | FilamentAsset::register( 57 | $this->getAssets(), 58 | $this->getAssetPackageName() 59 | ); 60 | 61 | FilamentAsset::registerScriptData( 62 | $this->getScriptData(), 63 | $this->getAssetPackageName() 64 | ); 65 | 66 | // Icon Registration 67 | FilamentIcon::register($this->getIcons()); 68 | 69 | // Testing 70 | Testable::mixin(new TestsIconPicker); 71 | } 72 | 73 | protected function getAssetPackageName(): ?string 74 | { 75 | return 'guava/filament-icon-picker'; 76 | } 77 | 78 | /** 79 | * @return array 80 | */ 81 | protected function getAssets(): array 82 | { 83 | return [ 84 | AlpineComponent::make('icon-picker-component', __DIR__ . '/../resources/dist/components/icon-picker-component.js'), 85 | ]; 86 | } 87 | 88 | /** 89 | * @return array 90 | */ 91 | protected function getIcons(): array 92 | { 93 | return []; 94 | } 95 | 96 | /** 97 | * @return array 98 | */ 99 | protected function getScriptData(): array 100 | { 101 | return []; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Icons/IconSet.php: -------------------------------------------------------------------------------- 1 | filesystem = app(Filesystem::class); 31 | $this->disks = app(FilesystemFactory::class); 32 | 33 | $this->label = $this->custom 34 | ? 'Custom icons' 35 | : str($this->id)->headline()->lower()->ucfirst(); 36 | } 37 | 38 | public function getId(): string 39 | { 40 | return $this->id; 41 | } 42 | 43 | public function getPrefix(): ?string 44 | { 45 | return $this->prefix; 46 | } 47 | 48 | public function getIcons(?Model $scopedTo = null, bool $checkScopes = true): Collection 49 | { 50 | $icons = collect(); 51 | foreach ($this->paths as $path) { 52 | $files = $this->filesystem($this->disk)->allFiles($path); 53 | foreach ($files as $file) { 54 | if (is_string($file)) { 55 | $file = new \SplFileInfo($file); 56 | } 57 | 58 | $name = str($file->getPathname()) 59 | ->after($path) 60 | ->trim(DIRECTORY_SEPARATOR) 61 | ->beforeLast(".{$file->getExtension()}") 62 | ->replace(DIRECTORY_SEPARATOR, '.') 63 | ; 64 | 65 | $id = "$this->prefix-$name"; 66 | 67 | if ($this->custom && $checkScopes) { 68 | if (! Validator::make(['icon' => $id], ['icon' => new VerifyIconScope($scopedTo)])->passes()) { 69 | continue; 70 | } 71 | $name = $name->after('.'); 72 | } 73 | 74 | // if ($allowedIcons && !in_array($filename, $allowedIcons)) { 75 | // continue; 76 | // } 77 | // if ($disallowedIcons && in_array($filename, $disallowedIcons)) { 78 | // continue; 79 | // } 80 | 81 | $icons->push(new Icon($id, $name, $this)); 82 | } 83 | } 84 | 85 | return $icons; 86 | } 87 | 88 | private function filesystem(?string $disk = null): \Illuminate\Contracts\Filesystem\Filesystem | Filesystem 89 | { 90 | return $this->disks && $disk ? $this->disks->disk($disk) : $this->filesystem; 91 | } 92 | 93 | public static function createFromArray(array $configuration, string $id): static 94 | { 95 | return app(static::class, [ 96 | 'id' => $id, 97 | 'prefix' => $configuration['prefix'] ?? null, 98 | 'fallback' => $configuration['fallback'] ?? null, 99 | 'class' => $configuration['class'] ?? null, 100 | 'attributes' => $configuration['attributes'] ?? [], 101 | 'paths' => $configuration['paths'] ?? [], 102 | 'disk' => $configuration['disk'] ?? null, 103 | 'custom' => $id === 'icon-picker-pro-icons', 104 | ]); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Forms/Components/IconPicker.php: -------------------------------------------------------------------------------- 1 | placeholder(__('filament-icon-picker::icon-picker.placeholder')) 43 | ->rules( 44 | fn (IconPicker $component) => collect([ 45 | new VerifyIcon($component), 46 | ]) 47 | ->when( 48 | $scopedTo = $component->getScopedTo(), 49 | fn (Collection $rules) => $rules->push(new VerifyIconScope($scopedTo)), 50 | ) 51 | ->all() 52 | ) 53 | ; 54 | } 55 | 56 | public function getHintActions(): array 57 | { 58 | if ($this->isCustomIconsUploadEnabled()) { 59 | return [ 60 | UploadCustomIcon::make() 61 | ->disabled($this->isDisabled()), 62 | ]; 63 | } 64 | 65 | return parent::getHintActions(); 66 | } 67 | 68 | public function getState(): mixed 69 | { 70 | return $this->verifyState( 71 | parent::getState() 72 | ); 73 | } 74 | 75 | public function getDisplayName(): ?string 76 | { 77 | if ($state = $this->getState()) { 78 | if ($icon = IconManager::getIcon($state)) { 79 | return $icon->label; 80 | } 81 | } 82 | 83 | return null; 84 | } 85 | 86 | #[ExposedLivewireMethod] 87 | #[Renderless] 88 | public function getSetJs(?string $state = null): ?string 89 | { 90 | if ($state) { 91 | return IconManager::getSetFromIcon($state)?->getId(); 92 | } 93 | 94 | return null; 95 | } 96 | 97 | #[ExposedLivewireMethod] 98 | #[Renderless] 99 | public function getIconsJs(?string $set = null): Collection 100 | { 101 | return IconManager::getIcons($set, $this->getScopedTo()); 102 | } 103 | 104 | #[ExposedLivewireMethod] 105 | #[Renderless] 106 | public function getIconSvgJs(?string $id = null): ?string 107 | { 108 | if (IconManager::getIcon($id, false)) { 109 | return generate_icon_html($id)?->toHtml(); 110 | } 111 | 112 | return null; 113 | } 114 | 115 | #[ExposedLivewireMethod] 116 | #[Renderless] 117 | public function verifyState(?string $state = null): ?string 118 | { 119 | if ($state && ! IconManager::getIcon($state)) { 120 | return null; 121 | } 122 | 123 | return $state; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Actions/UploadCustomIcon.php: -------------------------------------------------------------------------------- 1 | label(__('filament-icon-picker::actions.upload-custom-icon.label')) 27 | ->icon('heroicon-c-arrow-up-tray') 28 | ->modal() 29 | ->modalIcon(fn (UploadCustomIcon $action) => $action->getIcon()) 30 | ->schema(fn (IconPicker $component) => [ 31 | FileUpload::make('file') 32 | ->label(__('filament-icon-picker::actions.upload-custom-icon.schema.file.label')) 33 | ->acceptedFileTypes(['image/svg+xml']) 34 | ->disk('public') 35 | ->directory(function () use ($component): string { 36 | $directory = str('icon-picker-icons'); 37 | 38 | if ($model = $component->getScopedTo()) { 39 | $scopeId = md5("{$model->getMorphClass()}::{$model->getKey()}"); 40 | $directory = $directory->append(DIRECTORY_SEPARATOR, $scopeId); 41 | } else { 42 | $directory = $directory->append(DIRECTORY_SEPARATOR, 'unscoped'); 43 | } 44 | 45 | return $directory; 46 | }) 47 | ->getUploadedFileNameForStorageUsing( 48 | fn (TemporaryUploadedFile $file, Get $get): string => str($get('label')) 49 | ->lower() 50 | ->kebab() 51 | ->append('.svg') 52 | ) 53 | ->required(), 54 | 55 | TextInput::make('label') 56 | ->label(__('filament-icon-picker::actions.upload-custom-icon.schema.label.label')) 57 | ->extraAlpineAttributes([ 58 | 'x-on:input' => '$event.target.value = $event.target.value.replace(/[^a-zA-Z0-9\s]/g, \'\')', 59 | ]) 60 | ->rules([ 61 | fn (): Closure => function (string $attribute, $value, Closure $fail) use ($component) { 62 | $id = $this->getBladeIconId($value, $component->getScopedTo()); 63 | if (IconManager::getIcon($id)) { 64 | $fail(__('filament-icon-picker::validation.icon-already-exists')); 65 | } 66 | }, 67 | ]) 68 | ->required(), 69 | ]) 70 | ->after(function (array $data, IconPicker $component): void { 71 | $component->state($this->getBladeIconId( 72 | data_get($data, 'label'), 73 | $component->getScopedTo() 74 | )); 75 | $component->callAfterCustomIconUploaded(); 76 | }) 77 | ; 78 | } 79 | 80 | protected function getBladeIconId(string $label, ?Model $scope): string 81 | { 82 | return str($label) 83 | ->lower() 84 | ->kebab() 85 | ->when( 86 | $scope, 87 | function (Stringable $string) use ($scope) { 88 | $scopeId = md5("{$scope->getMorphClass()}::{$scope->getKey()}"); 89 | 90 | return $string->prepend("$scopeId."); 91 | }, 92 | fn (Stringable $string) => $string->prepend('unscoped.') 93 | ) 94 | ->prepend('_gfic_icons-') 95 | ; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Tables/Columns/IconColumn.php: -------------------------------------------------------------------------------- 1 | size = $size; 29 | 30 | return $this; 31 | } 32 | 33 | public function getSize(mixed $state): IconSize | string | null 34 | { 35 | $size = $this->evaluate($this->size, [ 36 | 'state' => $state, 37 | ]); 38 | 39 | if (blank($size)) { 40 | return null; 41 | } 42 | 43 | if ($size === 'base') { 44 | return null; 45 | } 46 | 47 | if (is_string($size)) { 48 | $size = IconSize::tryFrom($size) ?? $size; 49 | } 50 | 51 | return $size; 52 | } 53 | 54 | public function placeholder(Htmlable | string | Closure | null | BackedEnum $placeholder): static 55 | { 56 | if ($placeholder instanceof BackedEnum) { 57 | $placeholder = $this->getIconPlaceholder($placeholder); 58 | } 59 | 60 | return parent::placeholder($placeholder); 61 | } 62 | 63 | public function getPlaceholder(): string | Htmlable | null 64 | { 65 | $placeholder = parent::getPlaceholder(); 66 | 67 | if ($placeholder instanceof BackedEnum) { 68 | return $this->getIconPlaceholder($placeholder); 69 | } 70 | 71 | return $placeholder; 72 | } 73 | 74 | private function getIconPlaceholder(BackedEnum $placeholder): ?string 75 | { 76 | return generate_icon_html( 77 | $placeholder, 78 | attributes: (new ComponentAttributeBag) 79 | ) 80 | ->toHtml() 81 | ; 82 | } 83 | 84 | public function toEmbeddedHtml(): string 85 | { 86 | $state = $this->getState(); 87 | $color = $this->getColor($state); 88 | $size = $this->getSize($state); 89 | 90 | if (! IconManager::getIcon($state)) { 91 | $state = null; 92 | } 93 | 94 | $attributes = $this->getExtraAttributeBag() 95 | ->class([ 96 | 'fi-ta-icon', 97 | 'fi-inline' => $this->isInline(), 98 | ]) 99 | ; 100 | 101 | $alignment = $this->getAlignment(); 102 | 103 | $attributes = $attributes 104 | ->class([ 105 | ($alignment instanceof Alignment) ? "fi-align-{$alignment->value}" : (is_string($alignment) ? $alignment : ''), 106 | ]) 107 | ; 108 | 109 | ob_start(); ?> 110 | 111 |
class(['fi-ta-placeholder' => empty($state)])->toHtml() ?>> 112 | merge([ 115 | 'x-tooltip' => filled($tooltip = $this->getTooltip($state)) 116 | ? '{ 117 | content: ' . Js::from($tooltip) . ', 118 | theme: $store.theme, 119 | }' 120 | : null, 121 | ], escape: false) 122 | ->color(IconComponent::class, $color), size: $size ?? IconSize::Large) 123 | ->toHtml() 124 | : $this->getPlaceholder() ?> 125 |
126 | 127 | getState(); 11 | $isDropdown = $isDropdown(); 12 | $isDisabled = $isDisabled(); 13 | $shouldCloseOnSelect = $shouldCloseOnSelect(); 14 | $displayName = $getDisplayName(); 15 | $placeholder = $getPlaceholder(); 16 | @endphp 17 | 18 | 22 |
{ 32 | return await $wire.callSchemaComponentMethod(@js($key), 'getSetJs', { state }) 33 | }, 34 | getIconsUsing: async (set) => { 35 | return await $wire.callSchemaComponentMethod(@js($key), 'getIconsJs', { set }) 36 | }, 37 | getIconSvgUsing: async(id) => { 38 | return await $wire.callSchemaComponentMethod(@js($key), 'getIconSvgJs', { id }) 39 | }, 40 | verifyStateUsing: async(state) => { 41 | return await $wire.callSchemaComponentMethod(@js($key), 'verifyState', { state }) 42 | } 43 | })" 44 | {{ $getExtraAttributeBag() 45 | ->class([ 46 | 'flex flex-col gap-4' 47 | ]) 48 | }} 49 | > 50 | 61 | 62 |
63 | {{generate_loading_indicator_html()}} 64 | 70 | @if($state) 71 | {{generate_icon_html($state)}} 72 | @endif 73 | 74 |
75 |
76 | 77 | 78 | 79 | 80 | @if(!$isDisabled) 81 | 82 | {{ 83 | generate_icon_html( 84 | 'heroicon-s-x-mark', 85 | attributes: (new ComponentAttributeBag()) 86 | ->merge([ 87 | 'x-on:click.prevent.stop' => 'updateState(null)' 88 | ]) 89 | ->class([ 90 | 'opacity-50 text-black dark:text-white m-auto hover:cursor-pointer' 91 | ]) 92 | )}} 93 | 94 | @endif 95 | 96 | @if($isDropdown && !$isDisabled) 97 | 102 | @endif 103 |
104 | 105 | @if(! $isDropdown && ! $isDisabled) 106 | 111 | @endif 112 |
113 |
114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Icon Picker for your filament panels 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/guava/filament-icon-picker.svg?style=flat-square)](https://packagist.org/packages/guava/filament-icon-picker) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/guava/filament-icon-picker.svg?style=flat-square)](https://packagist.org/packages/guava/filament-icon-picker) 5 | 6 | 7 | 8 | This plugin adds a new icon picker form field and a corresponding table column. You can use it to select from any blade-icons kit that you have installed. By default, heroicons are supported since it is shipped with Filament. 9 | 10 | This can be useful for when you want to customize icons rendered on your frontend, if you want your users to be able to customize navigation icons, add small icons to their models for easy recognition and similar. 11 | 12 | ## Installation 13 | 14 | You can install the package via composer: 15 | 16 | **Filament v4:** 17 | ```bash 18 | composer require guava/filament-icon-picker:"^3.0" 19 | ``` 20 | 21 | Make sure to publish the package assets using: 22 | 23 | ```bash 24 | php artisan filament:assets 25 | ``` 26 | 27 | Finally, make sure you have a **custom filament theme** (read [here](https://filamentphp.com/docs/4.x/styling/overview#creating-a-custom-theme) how to create one) and add the following to your **theme.css** file: 28 | 29 | This ensures that the CSS is properly built: 30 | ```css 31 | @source '../../../../vendor/guava/filament-icon-picker/resources/**/*'; 32 | ``` 33 | 34 | For older filament versions, please check the branch of the respective version. 35 | 36 | ## Usage 37 | 38 | ### Usage in Schemas: 39 | Add the icon picker to any form schema in your filament panel or livewire component that supports filament forms: 40 | ```php 41 | use Guava\IconPicker\Forms\Components\IconPicker; 42 | 43 | IconPicker::make('icon'); 44 | ``` 45 | 46 | ### Usage in Tables: 47 | To display the stored icon in your filamen tables, use our IconColumn class: 48 | 49 | ```php 50 | // Make sure this is the correct import, not the filament one 51 | use Guava\IconPicker\Tables\Columns\IconColumn; 52 | 53 | $table 54 | ->columns([ 55 | IconColumn::make('icon'), 56 | ]) 57 | // ... 58 | ; 59 | ``` 60 | 61 | ### Usage on the frontend: 62 | We store the full icon name in the database. This means to use the icon on the frontend, simply treat is as any other static icon. 63 | 64 | For example, assuming we saved the icon on our `$category` model under `$icon`, you can render it in your blade view using: 65 | ```php 66 | 67 | ``` 68 | More information on rendering the icon on the [blade-icons github](https://github.com/blade-ui-kit/blade-icons#default-component). 69 | 70 | ## Customization 71 | 72 | ### Search Results View 73 | Out of the box, we provide three different search result views that you can choose from. 74 | 75 | #### Grid View 76 | This is the default view used. Icons will be shown in a grid with their name underneath the icon. 77 | 78 | ```php 79 | IconPicker::make('icon') 80 | ->gridSearchResults(); 81 | ``` 82 | 83 | Screenshot 2025-08-19 at 14 12 10 84 | 85 | #### List View 86 | Icons will be rendered in a list together with the icon's name. 87 | 88 | ```php 89 | IconPicker::make('icon') 90 | ->listSearchResults(); 91 | ``` 92 | 93 | Screenshot 2025-08-19 at 14 12 27 94 | 95 | #### Icons View 96 | Icons will be rendered in a small grid with only the icons visible, optionally configurable to show a tooltip with the icon name. 97 | 98 | ```php 99 | IconPicker::make('icon') 100 | ->iconsSearchResults() // With tooltip 101 | ->iconsSearchResults(false); // Without tooltip 102 | ``` 103 | 104 | Screenshot 2025-08-19 at 14 12 48 105 | 106 | ### Dropdown 107 | By default, the icon picker will open a dropdown, where you can search and select the icon. (Very similar to a regular `Select` field in filament). 108 | 109 | If you prefer, you can disable the dropdown and then the search and results will be rendered directly beneath the field. 110 | 111 | ```php 112 | IconPicker::make('icon') 113 | ->dropdown(false); 114 | ``` 115 | 116 | ### Limit sets 117 | By default, all available icon sets in the system will be available in the icon picker. 118 | 119 | If you want, you can limit the sets to only the sets you want, by providing an array of set names: 120 | 121 | ```php 122 | IconPicker::make('icon') 123 | ->sets(['heroicons']); 124 | ``` 125 | 126 | ## Testing 127 | 128 | ```bash 129 | composer test 130 | ``` 131 | 132 | ## Changelog 133 | 134 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 135 | 136 | ## Contributing 137 | 138 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 139 | 140 | ## Security Vulnerabilities 141 | 142 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 143 | 144 | ## Credits 145 | 146 | - [Lukas Frey](https://github.com/lukas-frey) 147 | - [All Contributors](../../contributors) 148 | 149 | ## License 150 | 151 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 152 | -------------------------------------------------------------------------------- /resources/js/components/icon-picker-component.js: -------------------------------------------------------------------------------- 1 | import Fuse from 'fuse.js'; 2 | 3 | export default function iconPickerComponent({ 4 | key, 5 | state, 6 | // selectedIcon, 7 | displayName, 8 | isDropdown, 9 | shouldCloseOnSelect, 10 | getSetUsing, 11 | getIconsUsing, 12 | getIconSvgUsing, 13 | verifyStateUsing, 14 | }) { 15 | return { 16 | state, 17 | displayName, 18 | isDropdown, 19 | shouldCloseOnSelect, 20 | dropdownOpen: false, 21 | set: null, 22 | icons: [], 23 | search: '', 24 | // selectedIcon, 25 | 26 | fuse: null, 27 | results: [], 28 | resultsVisible: [], 29 | minimumItems: 300, 30 | resultsPerPage: 50, 31 | resultsIndex: 0, 32 | 33 | isLoading: false, 34 | 35 | async init() { 36 | await verifyStateUsing(this.state) 37 | .then(result => this.state = result) 38 | 39 | await this.loadIcons() 40 | 41 | this.$wire.on(`custom-icon-uploaded::${key}`, (icon) => { 42 | this.displayName = icon.label 43 | this.set = icon.set 44 | this.afterSetUpdated() 45 | }) 46 | }, 47 | 48 | deferLoadingState() { 49 | return setTimeout(() => this.isLoading = true, 150); 50 | }, 51 | 52 | async loadIcons() { 53 | this.isLoading = true; 54 | return await getIconsUsing(this.set) 55 | .then((icons) => { 56 | this.icons = icons; 57 | this.createFuseObject() 58 | this.resetSearchResults() 59 | this.isLoading = false; 60 | }) 61 | }, 62 | 63 | async loadSet() { 64 | this.isLoading = true; 65 | return await getSetUsing(this.state).then((set) => { 66 | this.set = set 67 | this.isLoading = false; 68 | }) 69 | }, 70 | 71 | afterStateUpdated() { 72 | }, 73 | 74 | afterSetUpdated() { 75 | this.loadIcons() 76 | }, 77 | 78 | async updateSelectedIcon(reloadIfNotFound = true) { 79 | const found = this.icons.find(icon => icon.id === this.state); 80 | if (found) { 81 | } else if (reloadIfNotFound) { 82 | await this.loadSet() 83 | await this.loadIcons() 84 | await this.updateSelectedIcon(false) 85 | } 86 | }, 87 | 88 | setElementIcon(element, id, after = null) { 89 | getIconSvgUsing(id) 90 | .then((svg) => element.innerHTML = svg) 91 | .finally(after) 92 | }, 93 | 94 | createFuseObject() { 95 | const options = { 96 | includeScore: true, 97 | keys: ['id'] 98 | } 99 | 100 | this.fuse = new Fuse(this.icons, options) 101 | }, 102 | 103 | resetSearchResults() { 104 | this.resultsPerPage = 20; 105 | this.resultsIndex = 0; 106 | this.results = this.icons; 107 | this.resultsVisible = []; 108 | this.addSearchResultsChunk(); 109 | }, 110 | 111 | setSelect: { 112 | async ['x-on:change'](event) { 113 | const value = event.target.value; 114 | this.set = value ? value : null; 115 | 116 | this.afterSetUpdated() 117 | } 118 | }, 119 | 120 | searchInput: { 121 | ['x-on:input.debounce'](event) { 122 | const value = event.target.value 123 | const isLoadingDeferId = this.deferLoadingState() 124 | if (value.length) { 125 | this.resultsVisible = []; 126 | this.resultsIndex = 0; 127 | this.results = this.fuse.search(value).map(result => result.item); 128 | this.addSearchResultsChunk() 129 | } else { 130 | this.resetSearchResults() 131 | } 132 | clearTimeout(isLoadingDeferId) 133 | this.isLoading = false; 134 | }, 135 | }, 136 | 137 | dropdownTrigger: { 138 | ['x-on:click.prevent']() { 139 | this.dropdownOpen = true; 140 | } 141 | }, 142 | 143 | dropdownMenu: { 144 | ['x-show']() { 145 | return !this.isDropdown || this.dropdownOpen 146 | }, 147 | ['x-on:click.outside']() { 148 | this.dropdownOpen = false; 149 | } 150 | }, 151 | 152 | addSearchResultsChunk() { 153 | let endIndex = this.resultsIndex + this.resultsPerPage; 154 | if (endIndex < this.minimumItems) { 155 | endIndex = this.minimumItems; 156 | } 157 | this.resultsVisible.push(...this.results.slice(this.resultsIndex, endIndex)); 158 | this.resultsIndex = endIndex; 159 | }, 160 | 161 | updateState(icon) { 162 | if (icon) { 163 | this.state = icon.id; 164 | this.displayName = icon.label; 165 | if (this.shouldCloseOnSelect) { 166 | this.$nextTick(() => this.dropdownOpen = false); 167 | } 168 | } else { 169 | this.state = null; 170 | this.displayName = null; 171 | } 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /resources/dist/components/icon-picker-component.js: -------------------------------------------------------------------------------- 1 | function I(e){return Array.isArray?Array.isArray(e):ft(e)==="[object Array]"}var Et=1/0;function mt(e){if(typeof e=="string")return e;let t=e+"";return t=="0"&&1/e==-Et?"-0":t}function Dt(e){return e==null?"":mt(e)}function F(e){return typeof e=="string"}function lt(e){return typeof e=="number"}function Ft(e){return e===!0||e===!1||Bt(e)&&ft(e)=="[object Boolean]"}function dt(e){return typeof e=="object"}function Bt(e){return dt(e)&&e!==null}function C(e){return e!=null}function V(e){return!e.trim().length}function ft(e){return e==null?e===void 0?"[object Undefined]":"[object Null]":Object.prototype.toString.call(e)}var Mt="Incorrect 'index' type",It=e=>`Invalid value for key ${e}`,yt=e=>`Pattern length exceeds max of ${e}.`,_t=e=>`Missing ${e} property in key`,St=e=>`Property 'weight' in key '${e}' must be a positive integer`,ut=Object.prototype.hasOwnProperty,H=class{constructor(t){this._keys=[],this._keyMap={};let s=0;t.forEach(n=>{let i=gt(n);this._keys.push(i),this._keyMap[i.id]=i,s+=i.weight}),this._keys.forEach(n=>{n.weight/=s})}get(t){return this._keyMap[t]}keys(){return this._keys}toJSON(){return JSON.stringify(this._keys)}};function gt(e){let t=null,s=null,n=null,i=1,r=null;if(F(e)||I(e))n=e,t=ct(e),s=U(e);else{if(!ut.call(e,"name"))throw new Error(_t("name"));let c=e.name;if(n=c,ut.call(e,"weight")&&(i=e.weight,i<=0))throw new Error(St(c));t=ct(c),s=U(c),r=e.getFn}return{path:t,id:s,weight:i,src:n,getFn:r}}function ct(e){return I(e)?e:e.split(".")}function U(e){return I(e)?e.join("."):e}function wt(e,t){let s=[],n=!1,i=(r,c,o)=>{if(C(r))if(!c[o])s.push(r);else{let h=c[o],u=r[h];if(!C(u))return;if(o===c.length-1&&(F(u)||lt(u)||Ft(u)))s.push(Dt(u));else if(I(u)){n=!0;for(let a=0,d=u.length;ae.score===t.score?e.idx{this._keysMap[s.id]=n})}create(){this.isCreated||!this.docs.length||(this.isCreated=!0,F(this.docs[0])?this.docs.forEach((t,s)=>{this._addString(t,s)}):this.docs.forEach((t,s)=>{this._addObject(t,s)}),this.norm.clear())}add(t){let s=this.size();F(t)?this._addString(t,s):this._addObject(t,s)}removeAt(t){this.records.splice(t,1);for(let s=t,n=this.size();s{let c=i.getFn?i.getFn(t):this.getFn(t,i.path);if(C(c)){if(I(c)){let o=[],h=[{nestedArrIndex:-1,value:c}];for(;h.length;){let{nestedArrIndex:u,value:a}=h.pop();if(C(a))if(F(a)&&!V(a)){let d={v:a,i:u,n:this.norm.get(a)};o.push(d)}else I(a)&&a.forEach((d,f)=>{h.push({nestedArrIndex:f,value:d})})}n.$[r]=o}else if(F(c)&&!V(c)){let o={v:c,n:this.norm.get(c)};n.$[r]=o}}}),this.records.push(n)}toJSON(){return{keys:this.keys,records:this.records}}};function At(e,t,{getFn:s=l.getFn,fieldNormWeight:n=l.fieldNormWeight}={}){let i=new O({getFn:s,fieldNormWeight:n});return i.setKeys(e.map(gt)),i.setSources(t),i.create(),i}function Nt(e,{getFn:t=l.getFn,fieldNormWeight:s=l.fieldNormWeight}={}){let{keys:n,records:i}=e,r=new O({getFn:t,fieldNormWeight:s});return r.setKeys(n),r.setIndexRecords(i),r}function N(e,{errors:t=0,currentLocation:s=0,expectedLocation:n=0,distance:i=l.distance,ignoreLocation:r=l.ignoreLocation}={}){let c=t/e.length;if(r)return c;let o=Math.abs(n-s);return i?c+o/i:o?1:c}function $t(e=[],t=l.minMatchCharLength){let s=[],n=-1,i=-1,r=0;for(let c=e.length;r=t&&s.push([n,i]),n=-1)}return e[r-1]&&r-n>=t&&s.push([n,r-1]),s}var L=32;function Tt(e,t,s,{location:n=l.location,distance:i=l.distance,threshold:r=l.threshold,findAllMatches:c=l.findAllMatches,minMatchCharLength:o=l.minMatchCharLength,includeMatches:h=l.includeMatches,ignoreLocation:u=l.ignoreLocation}={}){if(t.length>L)throw new Error(yt(L));let a=t.length,d=e.length,f=Math.max(0,Math.min(n,d)),g=r,A=f,p=o>1||h,m=p?Array(d):[],_;for(;(_=e.indexOf(t,A))>-1;){let E=N(t,{currentLocation:_,expectedLocation:f,distance:i,ignoreLocation:u});if(g=Math.min(E,g),A=_+a,p){let S=0;for(;S=it;D-=1){let k=D-1,rt=s[e.charAt(k)];if(p&&(m[k]=+!!rt),R[D]=(R[D+1]<<1|1)&rt,E&&(R[D]|=(M[D+1]|M[D])<<1|1|M[D+1]),R[D]&Ct&&(b=N(t,{errors:E,currentLocation:k,expectedLocation:f,distance:i,ignoreLocation:u}),b<=g)){if(g=b,A=k,A<=f)break;it=Math.max(1,2*f-A)}}if(N(t,{errors:E+1,currentLocation:f,expectedLocation:f,distance:i,ignoreLocation:u})>g)break;M=R}let K={isMatch:A>=0,score:Math.max(.001,b)};if(p){let E=$t(m,o);E.length?h&&(K.indices=E):K.isMatch=!1}return K}function vt(e){let t={};for(let s=0,n=e.length;se.normalize("NFD").replace(/[\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08D3-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u09FE\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C04\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D00-\u0D03\u0D3B\u0D3C\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1AB0-\u1ABE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1CF7-\u1CF9\u1DC0-\u1DF9\u1DFB-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F]/g,""):e=>e,T=class{constructor(t,{location:s=l.location,threshold:n=l.threshold,distance:i=l.distance,includeMatches:r=l.includeMatches,findAllMatches:c=l.findAllMatches,minMatchCharLength:o=l.minMatchCharLength,isCaseSensitive:h=l.isCaseSensitive,ignoreDiacritics:u=l.ignoreDiacritics,ignoreLocation:a=l.ignoreLocation}={}){if(this.options={location:s,threshold:n,distance:i,includeMatches:r,findAllMatches:c,minMatchCharLength:o,isCaseSensitive:h,ignoreDiacritics:u,ignoreLocation:a},t=h?t:t.toLowerCase(),t=u?$(t):t,this.pattern=t,this.chunks=[],!this.pattern.length)return;let d=(g,A)=>{this.chunks.push({pattern:g,alphabet:vt(g),startIndex:A})},f=this.pattern.length;if(f>L){let g=0,A=f%L,p=f-A;for(;g{let{isMatch:M,score:b,indices:x}=Tt(t,p,m,{location:r+_,distance:c,threshold:o,findAllMatches:h,minMatchCharLength:u,includeMatches:i,ignoreLocation:a});M&&(g=!0),f+=b,M&&x&&(d=[...d,...x])});let A={isMatch:g,score:g?f/this.chunks.length:1};return g&&i&&(A.indices=d),A}},B=class{constructor(t){this.pattern=t}static isMultiMatch(t){return ot(t,this.multiRegex)}static isSingleMatch(t){return ot(t,this.singleRegex)}search(){}};function ot(e,t){let s=e.match(t);return s?s[1]:null}var z=class extends B{constructor(t){super(t)}static get type(){return"exact"}static get multiRegex(){return/^="(.*)"$/}static get singleRegex(){return/^=(.*)$/}search(t){let s=t===this.pattern;return{isMatch:s,score:s?0:1,indices:[0,this.pattern.length-1]}}},Y=class extends B{constructor(t){super(t)}static get type(){return"inverse-exact"}static get multiRegex(){return/^!"(.*)"$/}static get singleRegex(){return/^!(.*)$/}search(t){let n=t.indexOf(this.pattern)===-1;return{isMatch:n,score:n?0:1,indices:[0,t.length-1]}}},G=class extends B{constructor(t){super(t)}static get type(){return"prefix-exact"}static get multiRegex(){return/^\^"(.*)"$/}static get singleRegex(){return/^\^(.*)$/}search(t){let s=t.startsWith(this.pattern);return{isMatch:s,score:s?0:1,indices:[0,this.pattern.length-1]}}},Q=class extends B{constructor(t){super(t)}static get type(){return"inverse-prefix-exact"}static get multiRegex(){return/^!\^"(.*)"$/}static get singleRegex(){return/^!\^(.*)$/}search(t){let s=!t.startsWith(this.pattern);return{isMatch:s,score:s?0:1,indices:[0,t.length-1]}}},X=class extends B{constructor(t){super(t)}static get type(){return"suffix-exact"}static get multiRegex(){return/^"(.*)"\$$/}static get singleRegex(){return/^(.*)\$$/}search(t){let s=t.endsWith(this.pattern);return{isMatch:s,score:s?0:1,indices:[t.length-this.pattern.length,t.length-1]}}},J=class extends B{constructor(t){super(t)}static get type(){return"inverse-suffix-exact"}static get multiRegex(){return/^!"(.*)"\$$/}static get singleRegex(){return/^!(.*)\$$/}search(t){let s=!t.endsWith(this.pattern);return{isMatch:s,score:s?0:1,indices:[0,t.length-1]}}},v=class extends B{constructor(t,{location:s=l.location,threshold:n=l.threshold,distance:i=l.distance,includeMatches:r=l.includeMatches,findAllMatches:c=l.findAllMatches,minMatchCharLength:o=l.minMatchCharLength,isCaseSensitive:h=l.isCaseSensitive,ignoreDiacritics:u=l.ignoreDiacritics,ignoreLocation:a=l.ignoreLocation}={}){super(t),this._bitapSearch=new T(t,{location:s,threshold:n,distance:i,includeMatches:r,findAllMatches:c,minMatchCharLength:o,isCaseSensitive:h,ignoreDiacritics:u,ignoreLocation:a})}static get type(){return"fuzzy"}static get multiRegex(){return/^"(.*)"$/}static get singleRegex(){return/^(.*)$/}search(t){return this._bitapSearch.searchIn(t)}},P=class extends B{constructor(t){super(t)}static get type(){return"include"}static get multiRegex(){return/^'"(.*)"$/}static get singleRegex(){return/^'(.*)$/}search(t){let s=0,n,i=[],r=this.pattern.length;for(;(n=t.indexOf(this.pattern,s))>-1;)s=n+r,i.push([n,s-1]);let c=!!i.length;return{isMatch:c,score:c?0:1,indices:i}}},Z=[z,P,G,Q,J,X,Y,v],ht=Z.length,Pt=/ +(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/,jt="|";function Kt(e,t={}){return e.split(jt).map(s=>{let n=s.trim().split(Pt).filter(r=>r&&!!r.trim()),i=[];for(let r=0,c=n.length;r!!(e[j.AND]||e[j.OR]),Ht=e=>!!e[st.PATH],Ut=e=>!I(e)&&dt(e)&&!nt(e),at=e=>({[j.AND]:Object.keys(e).map(t=>({[t]:e[t]}))});function pt(e,t,{auto:s=!0}={}){let n=i=>{let r=Object.keys(i),c=Ht(i);if(!c&&r.length>1&&!nt(i))return n(at(i));if(Ut(i)){let h=c?i[st.PATH]:r[0],u=c?i[st.PATTERN]:i[h];if(!F(u))throw new Error(It(h));let a={keyId:U(h),pattern:u};return s&&(a.searcher=et(u,t)),a}let o={children:[],operator:r[0]};return r.forEach(h=>{let u=i[h];I(u)&&u.forEach(a=>{o.children.push(n(a))})}),o};return nt(e)||(e=at(e)),n(e)}function zt(e,{ignoreFieldNorm:t=l.ignoreFieldNorm}){e.forEach(s=>{let n=1;s.matches.forEach(({key:i,norm:r,score:c})=>{let o=i?i.weight:null;n*=Math.pow(c===0&&o?Number.EPSILON:c,(o||1)*(t?1:r))}),s.score=n})}function Yt(e,t){let s=e.matches;t.matches=[],C(s)&&s.forEach(n=>{if(!C(n.indices)||!n.indices.length)return;let{indices:i,value:r}=n,c={indices:i,value:r};n.key&&(c.key=n.key.src),n.idx>-1&&(c.refIndex=n.idx),t.matches.push(c)})}function Gt(e,t){t.score=e.score}function Qt(e,t,{includeMatches:s=l.includeMatches,includeScore:n=l.includeScore}={}){let i=[];return s&&i.push(Yt),n&&i.push(Gt),e.map(r=>{let{idx:c}=r,o={item:t[c],refIndex:c};return i.length&&i.forEach(h=>{h(r,o)}),o})}var y=class{constructor(t,s={},n){this.options={...l,...s},this.options.useExtendedSearch,this._keyStore=new H(this.options.keys),this.setCollection(t,n)}setCollection(t,s){if(this._docs=t,s&&!(s instanceof O))throw new Error(Mt);this._myIndex=s||At(this.options.keys,this._docs,{getFn:this.options.getFn,fieldNormWeight:this.options.fieldNormWeight})}add(t){C(t)&&(this._docs.push(t),this._myIndex.add(t))}remove(t=()=>!1){let s=[];for(let n=0,i=this._docs.length;n-1&&(h=h.slice(0,s)),Qt(h,this._docs,{includeMatches:n,includeScore:i})}_searchStringList(t){let s=et(t,this.options),{records:n}=this._myIndex,i=[];return n.forEach(({v:r,i:c,n:o})=>{if(!C(r))return;let{isMatch:h,score:u,indices:a}=s.searchIn(r);h&&i.push({item:r,idx:c,matches:[{score:u,value:r,norm:o,indices:a}]})}),i}_searchLogical(t){let s=pt(t,this.options),n=(o,h,u)=>{if(!o.children){let{keyId:d,searcher:f}=o,g=this._findMatches({key:this._keyStore.get(d),value:this._myIndex.getValueForItemAtKeyId(h,d),searcher:f});return g&&g.length?[{idx:u,item:h,matches:g}]:[]}let a=[];for(let d=0,f=o.children.length;d{if(C(o)){let u=n(s,o,h);u.length&&(r[h]||(r[h]={idx:h,item:o,matches:[]},c.push(r[h])),u.forEach(({matches:a})=>{r[h].matches.push(...a)}))}}),c}_searchObjectList(t){let s=et(t,this.options),{keys:n,records:i}=this._myIndex,r=[];return i.forEach(({$:c,i:o})=>{if(!C(c))return;let h=[];n.forEach((u,a)=>{h.push(...this._findMatches({key:u,value:c[a],searcher:s}))}),h.length&&r.push({idx:o,item:c,matches:h})}),r}_findMatches({key:t,value:s,searcher:n}){if(!C(s))return[];let i=[];if(I(s))s.forEach(({v:r,i:c,n:o})=>{if(!C(r))return;let{isMatch:h,score:u,indices:a}=n.searchIn(r);h&&i.push({score:u,key:t,value:r,idx:c,norm:o,indices:a})});else{let{v:r,n:c}=s,{isMatch:o,score:h,indices:u}=n.searchIn(r);o&&i.push({score:h,key:t,value:r,norm:c,indices:u})}return i}};y.version="7.1.0";y.createIndex=At;y.parseIndex=Nt;y.config=l;y.parseQuery=pt;Vt(q);function Xt({key:e,state:t,displayName:s,isDropdown:n,shouldCloseOnSelect:i,getSetUsing:r,getIconsUsing:c,getIconSvgUsing:o,verifyStateUsing:h}){return{state:t,displayName:s,isDropdown:n,shouldCloseOnSelect:i,dropdownOpen:!1,set:null,icons:[],search:"",fuse:null,results:[],resultsVisible:[],minimumItems:300,resultsPerPage:50,resultsIndex:0,isLoading:!1,async init(){await h(this.state).then(u=>this.state=u),await this.loadIcons(),this.$wire.on(`custom-icon-uploaded::${e}`,u=>{this.displayName=u.label,this.set=u.set,this.afterSetUpdated()})},deferLoadingState(){return setTimeout(()=>this.isLoading=!0,150)},async loadIcons(){return this.isLoading=!0,await c(this.set).then(u=>{this.icons=u,this.createFuseObject(),this.resetSearchResults(),this.isLoading=!1})},async loadSet(){return this.isLoading=!0,await r(this.state).then(u=>{this.set=u,this.isLoading=!1})},afterStateUpdated(){},afterSetUpdated(){this.loadIcons()},async updateSelectedIcon(u=!0){this.icons.find(d=>d.id===this.state)||u&&(await this.loadSet(),await this.loadIcons(),await this.updateSelectedIcon(!1))},setElementIcon(u,a,d=null){o(a).then(f=>u.innerHTML=f).finally(d)},createFuseObject(){let u={includeScore:!0,keys:["id"]};this.fuse=new y(this.icons,u)},resetSearchResults(){this.resultsPerPage=20,this.resultsIndex=0,this.results=this.icons,this.resultsVisible=[],this.addSearchResultsChunk()},setSelect:{async"x-on:change"(u){let a=u.target.value;this.set=a||null,this.afterSetUpdated()}},searchInput:{"x-on:input.debounce"(u){let a=u.target.value,d=this.deferLoadingState();a.length?(this.resultsVisible=[],this.resultsIndex=0,this.results=this.fuse.search(a).map(f=>f.item),this.addSearchResultsChunk()):this.resetSearchResults(),clearTimeout(d),this.isLoading=!1}},dropdownTrigger:{"x-on:click.prevent"(){this.dropdownOpen=!0}},dropdownMenu:{"x-show"(){return!this.isDropdown||this.dropdownOpen},"x-on:click.outside"(){this.dropdownOpen=!1}},addSearchResultsChunk(){let u=this.resultsIndex+this.resultsPerPage;uthis.dropdownOpen=!1)):(this.state=null,this.displayName=null)}}}export{Xt as default}; 2 | --------------------------------------------------------------------------------