diff --git a/.gitattributes b/.gitattributes
index 76da65a8..693e9e4b 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -33,6 +33,7 @@
/phpunit.xml.dist export-ignore
/psalm.xml export-ignore
/tests export-ignore
+/themes-preview export-ignore
/docs export-ignore
# Avoid merge conflicts in CHANGELOG
diff --git a/composer.json b/composer.json
index 1f3e7e8b..293a5a4b 100644
--- a/composer.json
+++ b/composer.json
@@ -21,7 +21,7 @@
"require": {
"php": "^8.1",
"yiisoft/friendly-exception": "^1.0",
- "yiisoft/html": "^3.3",
+ "yiisoft/html": "^3.4",
"yiisoft/widget": "^2.2"
},
"require-dev": {
diff --git a/config/theme-bootstrap5-horizontal.php b/config/theme-bootstrap5-horizontal.php
new file mode 100644
index 00000000..4808171c
--- /dev/null
+++ b/config/theme-bootstrap5-horizontal.php
@@ -0,0 +1,67 @@
+ "{label}\n
{input}\n{hint}\n{error}
",
+ 'containerClass' => 'mb-3 row',
+ 'labelClass' => 'col-sm-2 col-form-label',
+ 'inputClass' => 'form-control',
+ 'hintClass' => 'form-text',
+ 'errorClass' => 'invalid-feedback',
+ 'inputValidClass' => 'is-valid',
+ 'inputInvalidClass' => 'is-invalid',
+ 'fieldConfigs' => [
+ Checkbox::class => [
+ 'inputContainerTag()' => ['div'],
+ 'addInputContainerClass()' => ['form-check'],
+ 'inputClass()' => ['form-check-input'],
+ 'inputLabelClass()' => ['form-check-label'],
+ ],
+ CheckboxList::class => [
+ 'addCheckboxAttributes()' => [['class' => 'form-check-input']],
+ 'addCheckboxLabelAttributes()' => [['class' => 'form-check']],
+ ],
+ RadioList::class => [
+ 'addRadioAttributes()' => [['class' => 'form-check-input']],
+ 'addRadioLabelAttributes()' => [['class' => 'form-check']],
+ ],
+ ErrorSummary::class => [
+ 'containerClass()' => ['alert alert-danger'],
+ 'listAttributes()' => [['class' => 'mb-0']],
+ 'headerTag()' => ['h4'],
+ 'headerAttributes()' => [['class' => 'alert-heading']],
+ ],
+ Button::class => [
+ 'buttonClass()' => ['btn btn-secondary'],
+ ],
+ SubmitButton::class => [
+ 'buttonClass()' => ['btn btn-primary'],
+ ],
+ ResetButton::class => [
+ 'buttonClass()' => ['btn btn-secondary'],
+ ],
+ ButtonGroup::class => [
+ 'inputContainerTag()' => ['div'],
+ 'addInputContainerClass()' => ['btn-group'],
+ 'addButtonAttributes()' => [['class' => 'btn btn-secondary']],
+ ],
+ Range::class => [
+ 'inputClass()' => ['form-range'],
+ ],
+ Select::class => [
+ 'inputClass()' => ['form-select'],
+ ],
+ ],
+];
diff --git a/config/theme-bootstrap5-vertical.php b/config/theme-bootstrap5-vertical.php
new file mode 100644
index 00000000..562bc2bb
--- /dev/null
+++ b/config/theme-bootstrap5-vertical.php
@@ -0,0 +1,67 @@
+ "{label}\n{input}\n{hint}\n{error}",
+ 'containerClass' => 'mb-3',
+ 'labelClass' => 'form-label',
+ 'inputClass' => 'form-control',
+ 'hintClass' => 'form-text',
+ 'errorClass' => 'invalid-feedback',
+ 'inputValidClass' => 'is-valid',
+ 'inputInvalidClass' => 'is-invalid',
+ 'fieldConfigs' => [
+ Checkbox::class => [
+ 'inputContainerTag()' => ['div'],
+ 'addInputContainerClass()' => ['form-check'],
+ 'inputClass()' => ['form-check-input'],
+ 'inputLabelClass()' => ['form-check-label'],
+ ],
+ CheckboxList::class => [
+ 'addCheckboxAttributes()' => [['class' => 'form-check-input']],
+ 'addCheckboxLabelAttributes()' => [['class' => 'form-check']],
+ ],
+ RadioList::class => [
+ 'addRadioAttributes()' => [['class' => 'form-check-input']],
+ 'addRadioLabelAttributes()' => [['class' => 'form-check']],
+ ],
+ ErrorSummary::class => [
+ 'containerClass()' => ['alert alert-danger'],
+ 'listAttributes()' => [['class' => 'mb-0']],
+ 'headerTag()' => ['h4'],
+ 'headerAttributes()' => [['class' => 'alert-heading']],
+ ],
+ Button::class => [
+ 'buttonClass()' => ['btn btn-secondary'],
+ ],
+ SubmitButton::class => [
+ 'buttonClass()' => ['btn btn-primary'],
+ ],
+ ResetButton::class => [
+ 'buttonClass()' => ['btn btn-secondary'],
+ ],
+ ButtonGroup::class => [
+ 'inputContainerTag()' => ['div'],
+ 'addInputContainerClass()' => ['btn-group'],
+ 'addButtonAttributes()' => [['class' => 'btn btn-secondary']],
+ ],
+ Range::class => [
+ 'inputClass()' => ['form-range'],
+ ],
+ Select::class => [
+ 'inputClass()' => ['form-select'],
+ ],
+ ],
+];
diff --git a/src/Field/CheckboxList.php b/src/Field/CheckboxList.php
index ff8bee6b..7f292e8c 100644
--- a/src/Field/CheckboxList.php
+++ b/src/Field/CheckboxList.php
@@ -49,6 +49,20 @@ public function addCheckboxAttributes(array $attributes): self
return $new;
}
+ public function checkboxLabelAttributes(array $attributes): self
+ {
+ $new = clone $this;
+ $new->widget = $this->widget->checkboxLabelAttributes($attributes);
+ return $new;
+ }
+
+ public function addCheckboxLabelAttributes(array $attributes): self
+ {
+ $new = clone $this;
+ $new->widget = $this->widget->addCheckboxLabelAttributes($attributes);
+ return $new;
+ }
+
/**
* @param array[] $attributes
*/
diff --git a/src/Field/ErrorSummary.php b/src/Field/ErrorSummary.php
index 038ecfa6..a7feb99f 100644
--- a/src/Field/ErrorSummary.php
+++ b/src/Field/ErrorSummary.php
@@ -4,8 +4,10 @@
namespace Yiisoft\Form\Field;
+use InvalidArgumentException;
use Yiisoft\Form\Field\Base\BaseField;
use Yiisoft\Html\Html;
+use Yiisoft\Html\Tag\CustomTag;
use function in_array;
@@ -24,8 +26,15 @@ final class ErrorSummary extends BaseField
private string $footer = '';
private array $footerAttributes = [];
+
+ /**
+ * @var non-empty-string|null
+ */
+ private ?string $headerTag = 'div';
private string $header = '';
+ private bool $headerEncode = true;
private array $headerAttributes = [];
+
private array $listAttributes = [];
/**
@@ -111,6 +120,32 @@ public function header(string $value): self
return $new;
}
+ /**
+ * Set the header tag name.
+ *
+ * @param string|null $tag Header tag name.
+ */
+ public function headerTag(?string $tag): self
+ {
+ if ($tag === '') {
+ throw new InvalidArgumentException('Tag name cannot be empty.');
+ }
+
+ $new = clone $this;
+ $new->headerTag = $tag;
+ return $new;
+ }
+
+ /**
+ * Whether header content should be HTML-encoded.
+ */
+ public function headerEncode(bool $encode): self
+ {
+ $new = clone $this;
+ $new->headerEncode = $encode;
+ return $new;
+ }
+
/**
* Set header attributes for the error summary.
*
@@ -173,7 +208,13 @@ protected function generateContent(): ?string
$content = [];
if ($this->header !== '') {
- $content[] = Html::div($this->header, $this->headerAttributes)->render();
+ $content[] = $this->headerTag === null
+ ? ($this->headerEncode ? Html::encode($this->header) : $this->header)
+ : CustomTag::name($this->headerTag)
+ ->attributes($this->headerAttributes)
+ ->content($this->header)
+ ->encode($this->headerEncode)
+ ->render();
}
$content[] = Html::ul()
diff --git a/src/Field/RadioList.php b/src/Field/RadioList.php
index abd1c00b..34d2784a 100644
--- a/src/Field/RadioList.php
+++ b/src/Field/RadioList.php
@@ -47,6 +47,20 @@ public function addRadioAttributes(array $attributes): self
return $new;
}
+ public function radioLabelAttributes(array $attributes): self
+ {
+ $new = clone $this;
+ $new->widget = $this->widget->radioLabelAttributes($attributes);
+ return $new;
+ }
+
+ public function addRadioLabelAttributes(array $attributes): self
+ {
+ $new = clone $this;
+ $new->widget = $this->widget->addRadioLabelAttributes($attributes);
+ return $new;
+ }
+
/**
* @param array[] $attributes
*/
diff --git a/src/PureField.php b/src/PureField.php
index 1e953070..805cfac7 100644
--- a/src/PureField.php
+++ b/src/PureField.php
@@ -156,9 +156,15 @@ final public static function hidden(
->inputData(new PureInputData($name, $value));
}
- final public static function image(array $config = [], ?string $theme = null): Image
+ final public static function image(?string $url = null, array $config = [], ?string $theme = null): Image
{
- return Image::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME);
+ $field = Image::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME);
+
+ if ($url !== null) {
+ $field = $field->src($url);
+ }
+
+ return $field;
}
final public static function number(
diff --git a/src/ThemePath.php b/src/ThemePath.php
new file mode 100644
index 00000000..e51652b1
--- /dev/null
+++ b/src/ThemePath.php
@@ -0,0 +1,11 @@
+assertIsArray($result);
+ }
+
private function getBootstrapList(?array $params = null): array
{
if ($params === null) {
diff --git a/tests/Field/CheckboxListTest.php b/tests/Field/CheckboxListTest.php
index f99ee1cf..f96118c9 100644
--- a/tests/Field/CheckboxListTest.php
+++ b/tests/Field/CheckboxListTest.php
@@ -125,6 +125,48 @@ public function testCheckboxAttributes(): void
$this->assertSame($expected, $result);
}
+ public function testAddCheckboxLabelAttributes(): void
+ {
+ $result = CheckboxList::widget()
+ ->itemsFromValues(['Red', 'Blue'])
+ ->name('CheckboxListForm[color]')
+ ->addCheckboxLabelAttributes(['class' => 'control'])
+ ->addCheckboxLabelAttributes(['data-key' => 'x100'])
+ ->render();
+
+ $expected = <<
+
+
+
+
+
+ HTML;
+
+ $this->assertSame($expected, $result);
+ }
+
+ public function testCheckboxLabelAttributes(): void
+ {
+ $result = CheckboxList::widget()
+ ->itemsFromValues(['Red', 'Blue'])
+ ->name('CheckboxListForm[color]')
+ ->checkboxLabelAttributes(['data-key' => 'x100'])
+ ->checkboxLabelAttributes(['class' => 'control'])
+ ->render();
+
+ $expected = <<
+
+
+
+
+
+ HTML;
+
+ $this->assertSame($expected, $result);
+ }
+
public function testAddIndividualInputAttributes(): void
{
$result = CheckboxList::widget()
@@ -430,6 +472,8 @@ public function testImmutability(): void
$this->assertNotSame($field, $field->checkboxAttributes([]));
$this->assertNotSame($field, $field->addCheckboxAttributes([]));
+ $this->assertNotSame($field, $field->checkboxLabelAttributes([]));
+ $this->assertNotSame($field, $field->addCheckboxLabelAttributes([]));
$this->assertNotSame($field, $field->individualInputAttributes([]));
$this->assertNotSame($field, $field->addIndividualInputAttributes([]));
$this->assertNotSame($field, $field->items([]));
diff --git a/tests/Field/ErrorSummaryTest.php b/tests/Field/ErrorSummaryTest.php
index b18ec326..c8ff693c 100644
--- a/tests/Field/ErrorSummaryTest.php
+++ b/tests/Field/ErrorSummaryTest.php
@@ -4,6 +4,7 @@
namespace Yiisoft\Form\Tests\Field;
+use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Yiisoft\Form\Field\ErrorSummary;
@@ -142,6 +143,132 @@ public function testHeader(): void
$this->assertSame($expected, $result);
}
+ public function testHeaderTag(): void
+ {
+ $errors = ['key' => ['error1', 'error2']];
+
+ $result = ErrorSummary::widget()
+ ->errors($errors)
+ ->header('Field errors:')
+ ->headerTag('b')
+ ->render();
+
+ $this->assertSame(
+ <<
+ Field errors:
+
+
+ HTML,
+ $result
+ );
+ }
+
+ public function testHeaderWithoutTag(): void
+ {
+ $errors = ['key' => ['error1', 'error2']];
+
+ $result = ErrorSummary::widget()
+ ->errors($errors)
+ ->header('Field errors:')
+ ->headerTag(null)
+ ->render();
+
+ $this->assertSame(
+ <<
+ Field errors:
+
+
+ HTML,
+ $result
+ );
+ }
+
+ public function testEmptyHeaderTag(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Tag name cannot be empty.');
+ ErrorSummary::widget()->headerTag('');
+ }
+
+ public function testHeaderEncode(): void
+ {
+ $errors = ['key' => ['error1', 'error2']];
+
+ $result = ErrorSummary::widget()
+ ->errors($errors)
+ ->header('Field errors >')
+ ->render();
+
+ $this->assertSame(
+ <<
+ Field errors >
+
+
+ HTML,
+ $result
+ );
+ }
+
+ public function testHeaderEncodeWithutTag(): void
+ {
+ $errors = ['key' => ['error1', 'error2']];
+
+ $result = ErrorSummary::widget()
+ ->errors($errors)
+ ->header('Field errors >')
+ ->headerTag(null)
+ ->render();
+
+ $this->assertSame(
+ <<
+ Field errors >
+
+
+ HTML,
+ $result
+ );
+ }
+
+ public function testHeaderWithoutEncode(): void
+ {
+ $errors = ['key' => ['error1', 'error2']];
+
+ $result = ErrorSummary::widget()
+ ->errors($errors)
+ ->header('Field errors')
+ ->headerEncode(false)
+ ->render();
+
+ $this->assertSame(
+ <<
+ Field errors
+
+
+ HTML,
+ $result
+ );
+ }
+
public function testFooter(): void
{
$errors = ['key' => ['error1', 'error2']];
@@ -242,6 +369,8 @@ public function testImmutability(): void
$this->assertNotSame($field, $field->footerAttributes([]));
$this->assertNotSame($field, $field->header(''));
$this->assertNotSame($field, $field->headerAttributes([]));
+ $this->assertNotSame($field, $field->headerTag(null));
+ $this->assertNotSame($field, $field->headerEncode(true));
$this->assertNotSame($field, $field->listAttributes([]));
$this->assertNotSame($field, $field->addListClass());
$this->assertNotSame($field, $field->listClass());
diff --git a/tests/Field/RadioListTest.php b/tests/Field/RadioListTest.php
index 36913815..e60f21fa 100644
--- a/tests/Field/RadioListTest.php
+++ b/tests/Field/RadioListTest.php
@@ -120,6 +120,48 @@ public function testAddRadioAttributes(): void
$this->assertSame($expected, $result);
}
+ public function testAddRadioLabelAttributes(): void
+ {
+ $result = RadioList::widget()
+ ->itemsFromValues(['Red', 'Blue'])
+ ->name('RadioListForm[color]')
+ ->addRadioLabelAttributes(['class' => 'control'])
+ ->addRadioLabelAttributes(['data-key' => 'x100'])
+ ->render();
+
+ $expected = <<
+
+
+
+
+
+ HTML;
+
+ $this->assertSame($expected, $result);
+ }
+
+ public function testRadioLabelAttributes(): void
+ {
+ $result = RadioList::widget()
+ ->itemsFromValues(['Red', 'Blue'])
+ ->name('RadioListForm[color]')
+ ->radioLabelAttributes(['data-key' => 'x100'])
+ ->radioLabelAttributes(['class' => 'control'])
+ ->render();
+
+ $expected = <<
+
+
+
+
+
+ HTML;
+
+ $this->assertSame($expected, $result);
+ }
+
public function testRadioAttributesReplace(): void
{
$result = RadioList::widget()
@@ -625,6 +667,8 @@ public function testImmutability(): void
$this->assertNotSame($field, $field->radioAttributes([]));
$this->assertNotSame($field, $field->addRadioAttributes([]));
+ $this->assertNotSame($field, $field->addRadioLabelAttributes([]));
+ $this->assertNotSame($field, $field->radioLabelAttributes([]));
$this->assertNotSame($field, $field->individualInputAttributes([]));
$this->assertNotSame($field, $field->addIndividualInputAttributes([]));
$this->assertNotSame($field, $field->items([]));
diff --git a/tests/PureFieldTest.php b/tests/PureFieldTest.php
index 08fb60d6..761b5ebc 100644
--- a/tests/PureFieldTest.php
+++ b/tests/PureFieldTest.php
@@ -435,6 +435,19 @@ public function testImage(): void
$this->assertSame($expected, $html);
}
+ public function testImageWithUrl(): void
+ {
+ $html = PureField::image('image.png')->render();
+
+ $expected = <<
+
+
+ HTML;
+
+ $this->assertSame($expected, $html);
+ }
+
public function testImageWithTheme(): void
{
ThemeContainer::initialize([
diff --git a/themes-preview/.gitignore b/themes-preview/.gitignore
new file mode 100644
index 00000000..ba04124c
--- /dev/null
+++ b/themes-preview/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+package-lock.json
+*.html
diff --git a/themes-preview/Makefile b/themes-preview/Makefile
new file mode 100644
index 00000000..05bf3cca
--- /dev/null
+++ b/themes-preview/Makefile
@@ -0,0 +1,7 @@
+build: assets html
+
+assets:
+ docker run --rm -it -w /app/ -u `id -u`:`id -g` -v ./:/app node:21.5.0-alpine3.19 /app/assets.sh
+
+html:
+ docker run --rm -it -w /app/ -u `id -u`:`id -g` -v `realpath ../`:/app php:8.3-alpine3.19 /app/themes-preview/html.sh
diff --git a/themes-preview/assets.sh b/themes-preview/assets.sh
new file mode 100755
index 00000000..1fc77ad3
--- /dev/null
+++ b/themes-preview/assets.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+npm install --prefix /app/bootstrap5
diff --git a/themes-preview/bootstrap5/bootstrap5-horizontal.php b/themes-preview/bootstrap5/bootstrap5-horizontal.php
new file mode 100644
index 00000000..793520c2
--- /dev/null
+++ b/themes-preview/bootstrap5/bootstrap5-horizontal.php
@@ -0,0 +1,15 @@
+ 'Bootstrap 5 Horizontal',
+ 'file' => ThemePath::BOOTSTRAP5_HORIZONTAL,
+ 'head' => '',
+];
+
+require dirname(__DIR__) . '/template.php';
diff --git a/themes-preview/bootstrap5/bootstrap5-vertical.php b/themes-preview/bootstrap5/bootstrap5-vertical.php
new file mode 100644
index 00000000..e4a76b32
--- /dev/null
+++ b/themes-preview/bootstrap5/bootstrap5-vertical.php
@@ -0,0 +1,15 @@
+ 'Bootstrap 5 Vertical',
+ 'file' => ThemePath::BOOTSTRAP5_VERTICAL,
+ 'head' => '',
+];
+
+require dirname(__DIR__) . '/template.php';
diff --git a/themes-preview/bootstrap5/image-field.png b/themes-preview/bootstrap5/image-field.png
new file mode 100644
index 00000000..8710e361
Binary files /dev/null and b/themes-preview/bootstrap5/image-field.png differ
diff --git a/themes-preview/bootstrap5/package.json b/themes-preview/bootstrap5/package.json
new file mode 100644
index 00000000..c0942663
--- /dev/null
+++ b/themes-preview/bootstrap5/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "yiisoft-form-bootstrap5",
+ "dependencies": {
+ "bootstrap": "^5"
+ }
+}
diff --git a/themes-preview/html.sh b/themes-preview/html.sh
new file mode 100755
index 00000000..338baea9
--- /dev/null
+++ b/themes-preview/html.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+
+php "/app/themes-preview/bootstrap5/bootstrap5-vertical.php" > "/app/themes-preview/bootstrap5/bootstrap5-vertical.html"
+php "/app/themes-preview/bootstrap5/bootstrap5-horizontal.php" > "/app/themes-preview/bootstrap5/bootstrap5-horizontal.html"
diff --git a/themes-preview/template.php b/themes-preview/template.php
new file mode 100644
index 00000000..5c9c4cd6
--- /dev/null
+++ b/themes-preview/template.php
@@ -0,0 +1,114 @@
+ require $params['file'],
+ ],
+ 'theme',
+);
+
+echo '';
+?>
+
+
+
+
+ Yii Form — = Html::encode($params['name']) ?>
+ = $params['head'] ?>
+
+
+
+
+ label('Text Field')->placeholder('Placeholder')->hint('Example of hint');
+
+ echo Text::widget()
+ ->inputData(new PureInputData(validationErrors: []))
+ ->label('Valid Text Field')
+ ->placeholder('Placeholder');
+
+ echo PureField::text()->label('Invalid Text Field')->placeholder('Placeholder')->error('Example of error');
+
+ echo PureField::textarea()->label('Textarea Field')->placeholder('Placeholder');
+
+ echo PureField::password()->label('Password Field')->placeholder('Placeholder');
+
+ echo PureField::url()->label('Url Field')->placeholder('Placeholder');
+
+ echo PureField::email()->label('Email Field')->placeholder('Placeholder');
+
+ echo PureField::time()->label('Time Field');
+
+ echo PureField::date()->label('Date Field');
+
+ echo PureField::dateTime()->label('DateTime Field');
+
+ echo PureField::dateTimeLocal()->label('DateTimeLocal Field');
+
+ echo PureField::telephone()->label('Telephone Field')->placeholder('Placeholder');
+
+ echo PureField::number()->label('Number Field')->placeholder('Placeholder');
+
+ echo PureField::range()->label('Range Field');
+
+ echo PureField::select()
+ ->label('Select Field')
+ ->optionsData(['Red', 'Green', 'Blue']);
+
+ echo PureField::checkbox()->label('Checkbox Field');
+
+ echo PureField::checkboxList('checkbox-list')
+ ->label('Checkbox List Field')
+ ->itemsFromValues(['One', 'Two', 'Three']);
+
+ echo PureField::radioList('radio-list')
+ ->label('Radio List Field')
+ ->itemsFromValues(['One', 'Two', 'Three']);
+
+ echo PureField::file()->label('File Field');
+
+ echo PureField::image('image-field.png');
+
+ echo PureField::button('Button');
+
+ echo PureField::submitButton('Submit Button');
+
+ echo PureField::resetButton('Reset Button');
+
+ echo PureField::buttonGroup()->buttonsData([['This'], ['is'], ['button'], ['group']]);
+
+ $fieldset = PureField::fieldset()->legend('Fieldset');
+ echo $fieldset->begin();
+ echo PureField::text()->label('First Name');
+ echo PureField::text()->label('Last Name');
+ echo $fieldset::end();
+
+ echo PureField::errorSummary(
+ [
+ 'name' => ['Value not passed.'],
+ 'number' => ['Value must be no greater than 7.'],
+ 'colors' => ['Value must be array or iterable.'],
+ ]
+ )->header('Error Summary');
+
+ echo PureField::label('Label Example');
+
+ echo PureField::hint('Hint Example');
+
+ echo PureField::error('Error Example')->addAttributes(['style' => 'display: block;']);
+ ?>
+
+
+