Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions src/Html/FormBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,15 @@ 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 (.).
*/
public function select(string $name, array $list = [], string|array|null $selected = null, array $options = []): string
{
Expand Down Expand Up @@ -482,10 +491,15 @@ 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
*/
public function getSelectOption(string|array $display, string $value, string|array|null $selected = null): string
{
if (is_array($display)) {
if (is_array($display) && array_keys($display) !== [0,1]) {
return $this->optionGroup($display, $value, $selected);
}

Expand All @@ -508,16 +522,33 @@ 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')
*/
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);

$options = [
'value' => e($value),
'value' => $value,
'selected' => $selectedAttr
];

if (is_array($display)) {
$label = array_get($display, 0);
$data = array_get($display, 1);

if (is_string($data) && $data !== '') {
if (strpos($data, '.') !== false) {
$options['data-image'] = $data;
} else {
$options['data-icon'] = $data;
}
}
$display = $label;
}
return '<option' . $this->html->attributes($options) . '>' . e($display) . '</option>';
}

Expand Down
251 changes: 251 additions & 0 deletions tests/Html/FormBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -340,4 +340,255 @@ public function testSelectWithEmptyOption()
$this->assertStringContainsString('<option value="1">Option 1</option>', $result);
$this->assertStringContainsString('<option value="2">Option 2</option>', $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('<option value="1">Regular Option</option>', $result);
$this->assertStringContainsString('<option value="2" data-icon="icon-refresh">Option With Icon</option>', $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('<option value="1">Regular Option</option>', $result);
$this->assertStringContainsString('<option value="2" data-image="myImage.jpeg">Option With Image</option>', $result);
}

/**
* @testdox can create a select element with image data attributes.
*/
public function testSelectWithSelectedImage()
{
$result = $this->formBuilder->select(
name: 'my-select',
list: [
'1' => 'Regular Option',
'2' => ['Option With Image', 'myImage.jpeg'],
],
selected: '2',
options: []
);

$this->assertElementIs('select', $result);
$this->assertElementAttributeEquals('name', 'my-select', $result);
$this->assertStringContainsString('<option value="1">Regular Option</option>', $result);
$this->assertStringContainsString('<option value="2" selected="selected" data-image="myImage.jpeg">Option With Image</option>', $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('<optgroup label="Group 1">', $result);
$this->assertStringContainsString('<optgroup label="Group 2">', $result);
$this->assertStringContainsString('<option value="g1-opt1">Group 1 Option 1</option>', $result);
$this->assertStringContainsString('<option value="g1-opt2">Group 1 Option 2</option>', $result);
$this->assertStringContainsString('<option value="g2-opt1">Group 2 Option 1</option>', $result);
$this->assertStringContainsString('<option value="g2-opt2">Group 2 Option 2</option>', $result);
$this->assertStringContainsString('</optgroup>', $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('<option value="option1">Regular option</option>', $result);
$this->assertStringContainsString('<option value="option2" data-image="myImage.jpeg">Option With Image</option>', $result);

// Optgroups
$this->assertStringContainsString('<optgroup label="Group1">', $result);
$this->assertStringContainsString('<optgroup label="Group2">', $result);

// Options inside optgroups
$this->assertStringContainsString('<option value="group1-opt1">OptGroup Option1 regular option</option>', $result);
$this->assertStringContainsString('<option value="group1-opt2" data-icon="icon-refresh">OptGroup Option2 with icon</option>', $result);
$this->assertStringContainsString('<option value="group1-opt3" data-image="otherImage.png">OptGroup Option3 with image</option>', $result);
$this->assertStringContainsString('<option value="group2-opt1">OptGroup2 Option1</option>', $result);
$this->assertStringContainsString('<option value="group2-opt2">OptGroup2 Option2</option>', $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('<option value="1">Option 1</option>', $result);
$this->assertStringContainsString('<option value="2" selected="selected">Option 2</option>', $result);
$this->assertStringContainsString('<option value="3">Option 3</option>', $result);
$this->assertStringNotContainsString('data-icon', $result);
$this->assertStringNotContainsString('data-image', $result);
}

/**
* @testdox can create a select element with backward compatibility for optgroup integer keys
*/
public function testSelectBackwardCompatibilityOptgroupIdItemsKeys()
{
// this simulates grouped options base on a model with ids as keys
$result = $this->formBuilder->select(
name: 'my-select',
list: [
'Group1' => [
1 => 'Option 1',
2 => 'Option 2',
],
'Group2' => [
3 => 'Option 3',
4 => 'Option 4',
],
],
selected: 2,
options: []
);

$this->assertElementIs('select', $result);
$this->assertElementAttributeEquals('name', 'my-select', $result);

// Optgroups
$this->assertStringContainsString('<optgroup label="Group1">', $result);
$this->assertStringContainsString('<optgroup label="Group2">', $result);

// Options inside optgroups
$this->assertStringContainsString('<option value="1">Option 1</option>', $result);
$this->assertStringContainsString('<option value="2" selected="selected">Option 2</option>', $result);
$this->assertStringContainsString('<option value="3">Option 3</option>', $result);
$this->assertStringContainsString('<option value="4">Option 4</option>', $result);
$this->assertStringNotContainsString('data-icon', $result);
$this->assertStringNotContainsString('data-image', $result);
}

/**
* @testdox show case where backward compatibility is broken (expected)
*/
public function testSelectBackwardCompatibilityBrokenOptGroup()
{
// optgroup syntax with two items with integer keys starting at zero are seen as a regular option with an icon
$result = $this->formBuilder->select(
name: 'my-select',
list: [
'Group1' => [
0 => 'Option 1',
1 => 'Option 2',
],
],
options: []
);

$this->assertElementIs('select', $result);
$this->assertElementAttributeEquals('name', 'my-select', $result);

// Options inside optgroups
$this->assertStringContainsString('<option value="Group1" data-icon="Option 2">Option 1</option>', $result);
$this->assertStringContainsString('data-icon', $result);
}

/**
* @testdox properly escapes HTML in option labels and values.
*/
public function testSelectHtmlEscaping()
{
$result = $this->formBuilder->select(
name: 'my-select',
list: [
'<script>' => 'Normal Label',
'safe-value' => ['<b>Bold Label</b>', 'icon-test'],
],
selected: null,
options: []
);

$this->assertElementIs('select', $result);

$this->assertStringContainsString('value="&lt;script&gt;"', $result);
$this->assertStringContainsString('&lt;b&gt;Bold Label&lt;/b&gt;', $result);

// Ensure dangerous tags are not rendered as raw HTML
$this->assertStringNotContainsString('value="<script>"', $result);
$this->assertStringNotContainsString('<b>Bold Label</b>', $result);
}
}