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']) ?> + + + + +
+ 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;']); + ?> +
+ +