From 89d9f54643adfec8af428848aed202fc5d2c78bc Mon Sep 17 00:00:00 2001 From: Marc Jauvin Date: Sat, 6 Dec 2025 07:56:23 -0500 Subject: [PATCH 01/14] add image/icon support for FormBuilder::select --- src/Html/FormBuilder.php | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/Html/FormBuilder.php b/src/Html/FormBuilder.php index af5acdb0..9a0d4dc9 100644 --- a/src/Html/FormBuilder.php +++ b/src/Html/FormBuilder.php @@ -408,7 +408,7 @@ protected function setQuickTextAreaSize(array $options): array /** * Create a select box field with empty option support. */ - public function select(string $name, array $list = [], string|array|null $selected = null, array $options = []): string + public function select(string $name, array $list = [], string|array|null $selected = null, array $options = [], bool $legacyOptGroup = true): string { if (array_key_exists('emptyOption', $options)) { $list = ['' => $options['emptyOption']] + $list; @@ -431,7 +431,7 @@ public function select(string $name, array $list = [], string|array|null $select $html = []; foreach ($list as $value => $display) { - $html[] = $this->getSelectOption($display, $value, $selected); + $html[] = $this->getSelectOption($display, $value, $selected, $legacyOptGroup); } // Once we have all of this HTML, we can join this into a single element after @@ -482,10 +482,14 @@ public function selectMonth(string $name, string|array|null $selected = null, ar /** * Get the select option for the given value. */ - public function getSelectOption(string|array $display, string $value, string|array|null $selected = null): string + public function getSelectOption(string|array $display, string $value, string|array|null $selected = null, bool $legacyOptGroup = true): string { if (is_array($display)) { - return $this->optionGroup($display, $value, $selected); + if ($legacyOptGroup) { + return $this->optionGroup($display, $value, $selected); + } elseif ($items = array_get($display, 'items')) { + return $this->optionGroup($items, $value, $selected); + } } return $this->option($display, $value, $selected); @@ -508,7 +512,7 @@ protected function optionGroup(array $list, string $label, string|array|null $se /** * Create a select element option. */ - protected function option(string $display, string $value, string|array|null $selected = null): string + protected function option(string|array $display, string $value, string|array|null $selected = null): string { $selectedAttr = $this->getSelectedValue($value, $selected); @@ -517,6 +521,15 @@ protected function option(string $display, string $value, string|array|null $sel 'selected' => $selectedAttr ]; + if (is_array($display)) { + $data = array_get($display, 1); + $display = array_get($display, 0); + if (strpos($data, '.')) { + $options['data-image'] = $data; + } else { + $options['data-icon'] = $data; + } + } return 'html->attributes($options) . '>' . e($display) . ''; } From a49a2df65213e466a6def3842b3c5c2072b9a241 Mon Sep 17 00:00:00 2001 From: Marc Jauvin Date: Sat, 6 Dec 2025 08:28:18 -0500 Subject: [PATCH 02/14] add default icon if missing --- src/Html/FormBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Html/FormBuilder.php b/src/Html/FormBuilder.php index 9a0d4dc9..1aac84ad 100644 --- a/src/Html/FormBuilder.php +++ b/src/Html/FormBuilder.php @@ -522,7 +522,7 @@ protected function option(string|array $display, string $value, string|array|nul ]; if (is_array($display)) { - $data = array_get($display, 1); + $data = array_get($display, 1, 'icon-snowflake'); $display = array_get($display, 0); if (strpos($data, '.')) { $options['data-image'] = $data; From dcebe5b82470cf17dbb0d10b89c11b12fdd869c9 Mon Sep 17 00:00:00 2001 From: Marc Jauvin Date: Sat, 6 Dec 2025 09:29:45 -0500 Subject: [PATCH 03/14] use smarter way to detect optGroup vs image/icon formats --- src/Html/FormBuilder.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Html/FormBuilder.php b/src/Html/FormBuilder.php index 1aac84ad..4f142b2d 100644 --- a/src/Html/FormBuilder.php +++ b/src/Html/FormBuilder.php @@ -408,7 +408,7 @@ protected function setQuickTextAreaSize(array $options): array /** * Create a select box field with empty option support. */ - public function select(string $name, array $list = [], string|array|null $selected = null, array $options = [], bool $legacyOptGroup = true): string + public function select(string $name, array $list = [], string|array|null $selected = null, array $options = []): string { if (array_key_exists('emptyOption', $options)) { $list = ['' => $options['emptyOption']] + $list; @@ -431,7 +431,7 @@ public function select(string $name, array $list = [], string|array|null $select $html = []; foreach ($list as $value => $display) { - $html[] = $this->getSelectOption($display, $value, $selected, $legacyOptGroup); + $html[] = $this->getSelectOption($display, $value, $selected); } // Once we have all of this HTML, we can join this into a single element after @@ -482,13 +482,12 @@ public function selectMonth(string $name, string|array|null $selected = null, ar /** * Get the select option for the given value. */ - public function getSelectOption(string|array $display, string $value, string|array|null $selected = null, bool $legacyOptGroup = true): string + public function getSelectOption(string|array $display, string $value, string|array|null $selected = null): string { if (is_array($display)) { - if ($legacyOptGroup) { + $keys = array_keys($display); + if (count($keys) && gettype($keys[0]) === 'string') { return $this->optionGroup($display, $value, $selected); - } elseif ($items = array_get($display, 'items')) { - return $this->optionGroup($items, $value, $selected); } } @@ -522,7 +521,7 @@ protected function option(string|array $display, string $value, string|array|nul ]; if (is_array($display)) { - $data = array_get($display, 1, 'icon-snowflake'); + $data = array_get($display, 1, ''); $display = array_get($display, 0); if (strpos($data, '.')) { $options['data-image'] = $data; From 32e0b1a47c48aef37a5d803c64e1618d34ab3ee7 Mon Sep 17 00:00:00 2001 From: Marc Jauvin Date: Sat, 6 Dec 2025 09:32:44 -0500 Subject: [PATCH 04/14] apply coderabbit suggestion --- src/Html/FormBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Html/FormBuilder.php b/src/Html/FormBuilder.php index 4f142b2d..3e632215 100644 --- a/src/Html/FormBuilder.php +++ b/src/Html/FormBuilder.php @@ -523,7 +523,7 @@ protected function option(string|array $display, string $value, string|array|nul if (is_array($display)) { $data = array_get($display, 1, ''); $display = array_get($display, 0); - if (strpos($data, '.')) { + if (strpos($data, '.') !== false) { $options['data-image'] = $data; } else { $options['data-icon'] = $data; From d3bd9f8cc9d0553e95b73a5235e8d0792c926854 Mon Sep 17 00:00:00 2001 From: Marc Jauvin Date: Sat, 6 Dec 2025 09:46:34 -0500 Subject: [PATCH 05/14] use is_string() to improve readability --- src/Html/FormBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Html/FormBuilder.php b/src/Html/FormBuilder.php index 3e632215..7ca5b748 100644 --- a/src/Html/FormBuilder.php +++ b/src/Html/FormBuilder.php @@ -486,7 +486,7 @@ public function getSelectOption(string|array $display, string $value, string|arr { if (is_array($display)) { $keys = array_keys($display); - if (count($keys) && gettype($keys[0]) === 'string') { + if (count($keys) && is_string($keys[0])) { return $this->optionGroup($display, $value, $selected); } } From 922c0b49a3fda89d6304e992242734e2bf6d896b Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Sat, 6 Dec 2025 09:20:18 -0600 Subject: [PATCH 06/14] Add tests for new select option formats --- src/Html/FormBuilder.php | 34 +++++++ tests/Html/FormBuilderTest.php | 166 +++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) diff --git a/src/Html/FormBuilder.php b/src/Html/FormBuilder.php index 3b8defc4..4b3ba8b5 100644 --- a/src/Html/FormBuilder.php +++ b/src/Html/FormBuilder.php @@ -407,6 +407,21 @@ protected function setQuickTextAreaSize(array $options): array /** * Create a select box field with empty option support. + * + * Supports several formats for the $list parameter: + * - Simple format: ['value' => 'Label'] + * - With icon/image: ['value' => ['Label', 'icon-name']] or ['value' => ['Label', 'image.png']] + * - With optgroups: ['Group Name' => ['value' => 'Label', ...]] + * - Mixed format combining all of the above + * + * Icons are detected when the second array element doesn't contain a dot (.). + * Images are detected when the second array element contains a dot (.). + * + * @param string $name The name attribute for the select element + * @param array $list The options list (see above for supported formats) + * @param string|array|null $selected The selected value(s) + * @param array $options Additional HTML attributes for the select element + * @return string The generated HTML select element */ public function select(string $name, array $list = [], string|array|null $selected = null, array $options = []): string { @@ -482,6 +497,16 @@ public function selectMonth(string $name, string|array|null $selected = null, ar /** * Get the select option for the given value. + * + * Determines whether to create a single option or an optgroup based on the $display parameter: + * - If $display is an array with string keys, creates an optgroup + * - If $display is an array with numeric keys (e.g., ['Label', 'icon']), creates a single option with icon/image + * - If $display is a string, creates a simple option + * + * @param string|array $display The display value or array for optgroup/icon/image + * @param string $value The option value attribute + * @param string|array|null $selected The selected value(s) + * @return string The generated HTML option or optgroup element */ public function getSelectOption(string|array $display, string $value, string|array|null $selected = null): string { @@ -511,6 +536,15 @@ protected function optionGroup(array $list, string $label, string|array|null $se /** * Create a select element option. + * + * If $display is an array in the format ['Label', 'icon-or-image'], adds data attributes: + * - data-icon: added if the second element doesn't contain a dot (e.g., 'icon-refresh') + * - data-image: added if the second element contains a dot (e.g., 'image.png') + * + * @param string|array $display The display label or array with label and icon/image + * @param string $value The option value attribute + * @param string|array|null $selected The selected value(s) + * @return string The generated HTML option element */ protected function option(string|array $display, string $value, string|array|null $selected = null): string { diff --git a/tests/Html/FormBuilderTest.php b/tests/Html/FormBuilderTest.php index d5bcd740..b7a1dd95 100644 --- a/tests/Html/FormBuilderTest.php +++ b/tests/Html/FormBuilderTest.php @@ -340,4 +340,170 @@ public function testSelectWithEmptyOption() $this->assertStringContainsString('', $result); $this->assertStringContainsString('', $result); } + + /** + * @testdox can create a select element with icon data attributes. + */ + public function testSelectWithIcon() + { + $result = $this->formBuilder->select( + name: 'my-select', + list: [ + '1' => 'Regular Option', + '2' => ['Option With Icon', 'icon-refresh'], + ], + selected: null, + options: [] + ); + + $this->assertElementIs('select', $result); + $this->assertElementAttributeEquals('name', 'my-select', $result); + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + } + + /** + * @testdox can create a select element with image data attributes. + */ + public function testSelectWithImage() + { + $result = $this->formBuilder->select( + name: 'my-select', + list: [ + '1' => 'Regular Option', + '2' => ['Option With Image', 'myImage.jpeg'], + ], + selected: null, + options: [] + ); + + $this->assertElementIs('select', $result); + $this->assertElementAttributeEquals('name', 'my-select', $result); + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + } + + /** + * @testdox can create a select element with optgroups. + */ + public function testSelectWithOptgroups() + { + $result = $this->formBuilder->select( + name: 'my-select', + list: [ + 'Group 1' => [ + 'g1-opt1' => 'Group 1 Option 1', + 'g1-opt2' => 'Group 1 Option 2', + ], + 'Group 2' => [ + 'g2-opt1' => 'Group 2 Option 1', + 'g2-opt2' => 'Group 2 Option 2', + ], + ], + selected: null, + options: [] + ); + + $this->assertElementIs('select', $result); + $this->assertElementAttributeEquals('name', 'my-select', $result); + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + } + + /** + * @testdox can create a select element with optgroups containing icons and images. + */ + public function testSelectWithOptgroupsAndIconsImages() + { + $result = $this->formBuilder->select( + name: 'my-select', + list: [ + 'option1' => 'Regular option', + 'option2' => ['Option With Image', 'myImage.jpeg'], + 'Group1' => [ + 'group1-opt1' => 'OptGroup Option1 regular option', + 'group1-opt2' => ['OptGroup Option2 with icon', 'icon-refresh'], + 'group1-opt3' => ['OptGroup Option3 with image', 'otherImage.png'], + ], + 'Group2' => [ + 'group2-opt1' => 'OptGroup2 Option1', + 'group2-opt2' => 'OptGroup2 Option2', + ], + ], + selected: null, + options: [] + ); + + $this->assertElementIs('select', $result); + $this->assertElementAttributeEquals('name', 'my-select', $result); + + // Regular options + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + + // Optgroups + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + + // Options inside optgroups + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + } + + /** + * @testdox can create a select element with backward compatibility for simple string options. + */ + public function testSelectBackwardCompatibility() + { + $result = $this->formBuilder->select( + name: 'my-select', + list: [ + '1' => 'Option 1', + '2' => 'Option 2', + '3' => 'Option 3', + ], + selected: '2', + options: [] + ); + + $this->assertElementIs('select', $result); + $this->assertElementAttributeEquals('name', 'my-select', $result); + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('', $result); + $this->assertStringNotContainsString('data-icon', $result); + $this->assertStringNotContainsString('data-image', $result); + } + + /** + * @testdox properly escapes HTML in option labels and values. + */ + public function testSelectHtmlEscaping() + { + $result = $this->formBuilder->select( + name: 'my-select', + list: [ + '