From d87f6b811e588f277c40bc462b1a4eb28c0658ee Mon Sep 17 00:00:00 2001 From: Erin Dalzell Date: Wed, 12 Aug 2026 18:09:02 -0700 Subject: [PATCH 1/2] Add first-class icon support to Dictionary items Reserve an icon key on Statamic\Dictionaries\Item, mirroring label: it's excluded from data() but still present in extra()/toArray(). BasicDictionary skips it when matching search queries so raw SVG blobs aren't scanned. The dictionary fieldtype's options endpoint and preload data now surface the icon (a Statamic icon name or raw string) when one is set, omitting the key entirely otherwise. --- .../Commands/stubs/dictionary.php.stub | 2 +- src/Dictionaries/BasicDictionary.php | 4 ++ src/Dictionaries/Item.php | 7 ++- src/Fieldtypes/Dictionary.php | 5 +- .../DictionaryFieldtypeController.php | 17 ++++-- tests/Dictionaries/BasicDictionaryTest.php | 47 ++++++++++++++++ tests/Dictionaries/ItemTest.php | 27 +++++++++ tests/Fieldtypes/DictionaryTest.php | 55 +++++++++++++++++++ 8 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 tests/Dictionaries/BasicDictionaryTest.php diff --git a/src/Console/Commands/stubs/dictionary.php.stub b/src/Console/Commands/stubs/dictionary.php.stub index 05b2bc54ef9..7979da30547 100644 --- a/src/Console/Commands/stubs/dictionary.php.stub +++ b/src/Console/Commands/stubs/dictionary.php.stub @@ -12,7 +12,7 @@ class DummyClass extends BasicDictionary protected function getItems(): array { return [ - ['name' => 'Alabama', 'abbr' => 'AL', 'capital' => 'Montgomery'], + ['name' => 'Alabama', 'abbr' => 'AL', 'capital' => 'Montgomery', 'icon' => 'map-pin'], ['name' => 'Alaska', 'abbr' => 'AK', 'capital' => 'Juneau'], ['name' => 'Arizona', 'abbr' => 'AZ', 'capital' => 'Phoenix'], // ... diff --git a/src/Dictionaries/BasicDictionary.php b/src/Dictionaries/BasicDictionary.php index 77cb0d672a5..ded276c343a 100644 --- a/src/Dictionaries/BasicDictionary.php +++ b/src/Dictionaries/BasicDictionary.php @@ -62,6 +62,10 @@ protected function matchesSearchQuery(string $query, Item $item): bool $searchableLookup = empty($this->searchable) ? null : array_flip($this->searchable); foreach ($item->extra() as $key => $value) { + if ($key === 'icon') { + continue; + } + if ($searchableLookup !== null && ! isset($searchableLookup[$key])) { continue; } diff --git a/src/Dictionaries/Item.php b/src/Dictionaries/Item.php index 5ee458f75a9..3247c072742 100644 --- a/src/Dictionaries/Item.php +++ b/src/Dictionaries/Item.php @@ -16,9 +16,14 @@ public function __construct($value, $label, array $extra) ); } + public function icon(): ?string + { + return $this->extra['icon'] ?? null; + } + public function data(): array { - return Arr::except($this->extra, ['label']); + return Arr::except($this->extra, ['label', 'icon']); } public function offsetExists(mixed $offset): bool diff --git a/src/Fieldtypes/Dictionary.php b/src/Fieldtypes/Dictionary.php index fd1358d015f..128e33fc090 100644 --- a/src/Fieldtypes/Dictionary.php +++ b/src/Fieldtypes/Dictionary.php @@ -86,11 +86,12 @@ private function getItemData($values) return collect($values)->map(function ($key) { $item = $this->dictionary()->get($key); - return [ + return array_filter([ 'value' => $item?->value() ?? $key, 'label' => $item?->label() ?? $key, + 'icon' => $item?->icon(), 'invalid' => ! $item, - ]; + ], fn ($v, $k) => $k !== 'icon' || $v !== null, ARRAY_FILTER_USE_BOTH); })->values()->all(); } diff --git a/src/Http/Controllers/DictionaryFieldtypeController.php b/src/Http/Controllers/DictionaryFieldtypeController.php index a54ee4ade84..8bfd033f35a 100644 --- a/src/Http/Controllers/DictionaryFieldtypeController.php +++ b/src/Http/Controllers/DictionaryFieldtypeController.php @@ -25,14 +25,23 @@ public function __invoke(Request $request, string $dictionary) throw new ForbiddenHttpException; } - $options = $dictionary->options($request->search); - // Return an ordered list of key/value pairs rather than a value-keyed object. // When the values are integers, the browser would re-sort the object's keys ascending, // discarding the dictionary's own order. return [ - 'data' => collect($options) - ->map(fn ($label, $key) => ['key' => (string) $key, 'value' => $label]) + 'data' => collect($dictionary->optionItems($request->search)) + ->map(function ($item) { + $option = [ + 'key' => (string) $item->value(), + 'value' => $item->label(), + ]; + + if ($icon = $item->icon()) { + $option['icon'] = $icon; + } + + return $option; + }) ->values() ->all(), ]; diff --git a/tests/Dictionaries/BasicDictionaryTest.php b/tests/Dictionaries/BasicDictionaryTest.php new file mode 100644 index 00000000000..7af68c58b8c --- /dev/null +++ b/tests/Dictionaries/BasicDictionaryTest.php @@ -0,0 +1,47 @@ +assertCount(0, $dictionary->optionItems('svg')); + $this->assertCount(0, $dictionary->optionItems('map-pin')); + $this->assertCount(1, $dictionary->optionItems('Alabama')); + } + + #[Test] + public function option_items_expose_the_icon() + { + $dictionary = new IconSearchDictionary; + + $items = collect($dictionary->optionItems()); + + $this->assertEquals('map-pin', $items->get('AL')->icon()); + $this->assertEquals('map-pin', $items->get('AK')->icon()); + $this->assertNull($items->get('AZ')->icon()); + } +} + +class IconSearchDictionary extends BasicDictionary +{ + protected string $valueKey = 'abbr'; + protected string $labelKey = 'name'; + + protected function getItems(): array + { + return [ + ['name' => 'Alabama', 'abbr' => 'AL', 'icon' => 'map-pin'], + ['name' => 'Alaska', 'abbr' => 'AK', 'icon' => 'map-pin'], + ['name' => 'Arizona', 'abbr' => 'AZ'], + ]; + } +} diff --git a/tests/Dictionaries/ItemTest.php b/tests/Dictionaries/ItemTest.php index 6dc623060ba..552cdca125c 100644 --- a/tests/Dictionaries/ItemTest.php +++ b/tests/Dictionaries/ItemTest.php @@ -28,4 +28,31 @@ public function it_gets_value_label_and_data() 'label' => '🍎 Apple', ], $item->toArray()); } + + #[Test] + public function it_gets_the_icon() + { + $item = new Item('apple', 'Apple', [ + 'icon' => 'apple', + 'color' => 'red', + ]); + + $this->assertEquals('apple', $item->icon()); + $this->assertEquals(['color' => 'red'], $item->data()); + $this->assertEquals([ + 'key' => 'apple', + 'value' => 'apple', + 'icon' => 'apple', + 'color' => 'red', + 'label' => 'Apple', + ], $item->toArray()); + } + + #[Test] + public function icon_is_null_when_not_set() + { + $item = new Item('apple', 'Apple', ['color' => 'red']); + + $this->assertNull($item->icon()); + } } diff --git a/tests/Fieldtypes/DictionaryTest.php b/tests/Fieldtypes/DictionaryTest.php index ba8351fdcc4..65b7f11de31 100644 --- a/tests/Fieldtypes/DictionaryTest.php +++ b/tests/Fieldtypes/DictionaryTest.php @@ -5,6 +5,7 @@ use Facades\Statamic\Fields\FieldtypeRepository; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Dictionaries\BasicDictionary; use Statamic\Dictionaries\Countries; use Statamic\Dictionaries\Dictionary; use Statamic\Dictionaries\Item; @@ -321,6 +322,44 @@ public function it_filters_out_invalid_values_when_augmenting_multiple() ], collect($augment)->toArray()); } + #[Test] + public function it_includes_icons_in_preload_data_when_present() + { + IconDictionary::register(); + + $field = (new Field('test', ['type' => 'dictionary', 'dictionary' => 'icon'])); + $field->setValue(['AL', 'AK']); + + $fieldtype = FieldtypeRepository::find('dictionary'); + $fieldtype->setField($field); + + $preload = $fieldtype->preload(); + + $this->assertEquals([ + ['value' => 'AL', 'label' => 'Alabama', 'icon' => 'map-pin', 'invalid' => false], + ['value' => 'AK', 'label' => 'Alaska', 'invalid' => false], + ], $preload['selectedOptions']); + } + + #[Test] + public function the_options_api_returns_icons_when_present() + { + IconDictionary::register(); + + $config = base64_encode(json_encode(['type' => 'dictionary', 'dictionary' => 'icon'])); + + $this + ->actingAs(User::make()->makeSuper()) + ->getJson(route('statamic.dictionary-fieldtype', 'icon').'?config='.$config) + ->assertOk() + ->assertExactJson([ + 'data' => [ + ['key' => 'AL', 'value' => 'Alabama', 'icon' => 'map-pin'], + ['key' => 'AK', 'value' => 'Alaska'], + ], + ]); + } + #[Test] public function it_returns_extra_renderable_field_data() { @@ -358,3 +397,19 @@ public function get(string $key): ?Item return new Item($key, $this->options()[$key], []); } } + +class IconDictionary extends BasicDictionary +{ + protected static $handle = 'icon'; + + protected string $valueKey = 'abbr'; + protected string $labelKey = 'name'; + + protected function getItems(): array + { + return [ + ['name' => 'Alabama', 'abbr' => 'AL', 'icon' => 'map-pin'], + ['name' => 'Alaska', 'abbr' => 'AK'], + ]; + } +} From 1b9af47b92e7036c314101cbd273e62c032d7032 Mon Sep 17 00:00:00 2001 From: Erin Dalzell Date: Wed, 12 Aug 2026 18:09:05 -0700 Subject: [PATCH 2/2] Render per-item icons in Combobox and Dictionary fieldtype Icon.vue already renders either a registered icon name or a raw string, so surface an option's icon (dropdown rows, single-select label, and multi-select badges) wherever it's provided. HasInputOptions now preserves the icon key when normalizing object-shaped options, and DictionaryFieldtype carries it through selectedOptions so it survives the round trip through selectedOptionData. --- .../fieldtypes/DictionaryFieldtype.vue | 5 +++- .../components/fieldtypes/HasInputOptions.js | 1 + .../js/components/ui/Combobox/Combobox.vue | 6 +++-- .../js/tests/NormalizeInputOptions.test.js | 24 +++++++++++++++++++ .../fieldtypes/DictionaryFieldtype.test.js | 19 +++++++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/resources/js/components/fieldtypes/DictionaryFieldtype.vue b/resources/js/components/fieldtypes/DictionaryFieldtype.vue index 3a695d07daf..12042303aa7 100644 --- a/resources/js/components/fieldtypes/DictionaryFieldtype.vue +++ b/resources/js/components/fieldtypes/DictionaryFieldtype.vue @@ -36,6 +36,7 @@ class="sortable-item cursor-grab active:cursor-grabbing" > +
{{ __(getOptionLabel(option)) }}
@@ -65,7 +66,7 @@ import Fieldtype from './Fieldtype.vue'; import HasInputOptions from './HasInputOptions.js'; import { SortableList } from '../sortable/Sortable'; import debounce from '@/util/debounce.js'; -import { Badge, Combobox } from '@/components/ui'; +import { Badge, Combobox, Icon } from '@/components/ui'; export default { mixins: [Fieldtype, HasInputOptions], @@ -73,6 +74,7 @@ export default { components: { Badge, Combobox, + Icon, SortableList, }, @@ -119,6 +121,7 @@ export default { label: DOMPurify.sanitize(option.label, { USE_PROFILES: { html: true, svg: true }, }), + ...(option.icon ? { icon: option.icon } : {}), invalid: option.invalid }; }); diff --git a/resources/js/components/fieldtypes/HasInputOptions.js b/resources/js/components/fieldtypes/HasInputOptions.js index 600d83a1e94..633aef81adf 100644 --- a/resources/js/components/fieldtypes/HasInputOptions.js +++ b/resources/js/components/fieldtypes/HasInputOptions.js @@ -26,6 +26,7 @@ export default { return { value: option[valueKey], label: `${__(option[labelKey]) || option[valueKey]}`, + ...(option.icon ? { icon: option.icon } : {}), }; } diff --git a/resources/js/components/ui/Combobox/Combobox.vue b/resources/js/components/ui/Combobox/Combobox.vue index 9aaf4d37171..ae372b4d0c3 100644 --- a/resources/js/components/ui/Combobox/Combobox.vue +++ b/resources/js/components/ui/Combobox/Combobox.vue @@ -414,8 +414,8 @@ defineExpose({ data-ui-combobox-selected-option > -
- +
+
@@ -504,6 +504,7 @@ defineExpose({ > + {{ __(getOptionLabel(option)) }} @@ -547,6 +548,7 @@ defineExpose({ class="sortable-item mt-2 cursor-grab active:cursor-grabbing" > +
{{ __(getOptionLabel(option)) }}
diff --git a/resources/js/tests/NormalizeInputOptions.test.js b/resources/js/tests/NormalizeInputOptions.test.js index 8997a8b58ef..5855fe77182 100644 --- a/resources/js/tests/NormalizeInputOptions.test.js +++ b/resources/js/tests/NormalizeInputOptions.test.js @@ -54,3 +54,27 @@ it('normalizes input options with array of objects with key value keys', () => { { value: 'two', label: 'Two' }, ]); }); + +it('preserves icon when normalizing object options with value label keys', () => { + expect( + normalizeInputOptions([ + { value: 'one', label: 'One', icon: 'globe' }, + { value: 'two', label: 'Two' }, + ]), + ).toEqual([ + { value: 'one', label: 'Uno', icon: 'globe' }, + { value: 'two', label: 'Two' }, + ]); +}); + +it('preserves icon when normalizing object options with key value keys', () => { + expect( + normalizeInputOptions([ + { key: 'one', value: 'One', icon: 'globe' }, + { key: 'two', value: 'Two' }, + ]), + ).toEqual([ + { value: 'one', label: 'Uno', icon: 'globe' }, + { value: 'two', label: 'Two' }, + ]); +}); diff --git a/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js b/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js index a303c9bbaac..c6d95d99c35 100644 --- a/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js +++ b/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js @@ -68,4 +68,23 @@ describe('DictionaryFieldtype options', () => { expect(fieldtype.vm.normalizedOptions).toEqual([{ value: 'ca', label: 'Canada' }]); }); + + test('selected options carry their icon when present', async () => { + const fieldtype = mountFieldtype({ + value: ['de', 'fr'], + maxItems: null, + selectedOptions: [ + { value: 'de', label: 'Germany', icon: 'globe', invalid: false }, + { value: 'fr', label: 'France', invalid: false }, + ], + fetchedOptions: [], + shallow: true, + }); + await flushPromises(); + + expect(fieldtype.vm.selectedOptions).toEqual([ + { value: 'de', label: 'Germany', icon: 'globe', invalid: false }, + { value: 'fr', label: 'France', invalid: false }, + ]); + }); });