diff --git a/CHANGELOG.md b/CHANGELOG.md index 633ff315..8437ccc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.4.1 under development -- no changes in this release. +- New #377: Add `Color` field (@samdark) ## 1.4.0 March 27, 2025 diff --git a/README.md b/README.md index 7408593d..8c1a1b8a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The package provides a set of widgets to help with dynamic server-side generation of HTML forms. The following widgets are available out of the box: -- input fields: `Checkbox`, `CheckboxList`, `Date`, `DateTimeLocal`, `Email`, `File`, `Hidden`, `Image`, +- input fields: `Checkbox`, `CheckboxList`, `Color`, `Date`, `DateTimeLocal`, `Email`, `File`, `Hidden`, `Image`, `Number`, `Password`, `RadioList`, `Range`, `Select`, `Telephone`, `Text`, `Textarea`, `Time`, `Url`; - buttons: `Button`, `ResetButton`, `SubmitButton`; - group widgets: `ButtonGroup`, `Fieldset`. @@ -65,6 +65,7 @@ use Yiisoft\Form\PureField\Field; echo Field::text('firstName', theme: 'horizontal')->label('First Name')->autofocus(); echo Field::text('lastName', theme: 'horizontal')->label('Last Name'); +echo Field::color('favoriteColor')->label('Favorite Color')->value('#3498db'); echo Field::select('sex')->label('Sex')->optionsData(['m' => 'Male', 'f' => 'Female'])->prompt('—'); echo Field::number('age')->label('Age')->hint('Please enter your age.'); echo Field::submitButton('Submit')->buttonClass('primary'); @@ -85,6 +86,10 @@ The result of executing the code above will be: +
+ + +
+
Select a background color.
+
+ HTML, + new InputData( + name: 'ColorForm[bgcolor]', + value: null, + label: 'Background Color', + hint: 'Select a background color.', + id: 'colorform-bgcolor', + ), + ], + 'input-valid-class' => [ + << + + + HTML, + new InputData( + name: 'color', + value: null, + validationErrors: [], + ), + ['inputValidClass' => 'valid', 'inputInvalidClass' => 'invalid'], + ], + 'container-valid-class' => [ + << + + + HTML, + new InputData( + name: 'color', + value: null, + validationErrors: [], + ), + ['validClass' => 'valid', 'invalidClass' => 'invalid'], + ], + 'value' => [ + << + + + HTML, + new InputData( + name: 'color', + value: '#ff0000', + ), + ], + ]; + } + + #[DataProvider('dataBase')] + public function testBase(string $expected, InputData $inputData, array $theme = []): void + { + ThemeContainer::initialize( + configs: ['default' => $theme], + defaultConfig: 'default', + ); + + $result = Color::widget()->inputData($inputData)->render(); + + $this->assertSame($expected, $result); + } + + public function testReadonly(): void + { + $result = Color::widget() + ->name('test') + ->readonly() + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testRequired(): void + { + $result = Color::widget() + ->name('test') + ->required() + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testDisabled(): void + { + $result = Color::widget() + ->name('test') + ->disabled() + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public static function dataAriaDescribedBy(): array + { + return [ + 'one element' => [ + ['hint'], + << + + + HTML, + ], + 'multiple elements' => [ + ['hint1', 'hint2'], + << + + + HTML, + ], + 'null with other elements' => [ + ['hint1', null, 'hint2', null, 'hint3'], + << + + + HTML, + ], + 'only null' => [ + [null, null], + << + + + HTML, + ], + 'empty string' => [ + [''], + << + + + HTML, + ], + ]; + } + + #[DataProvider('dataAriaDescribedBy')] + public function testAriaDescribedBy(array $ariaDescribedBy, string $expectedHtml): void + { + $actualHtml = Color::widget() + ->name('test') + ->ariaDescribedBy(...$ariaDescribedBy) + ->hideLabel() + ->render(); + + $this->assertSame($expectedHtml, $actualHtml); + } + + public function testAriaLabel(): void + { + $result = Color::widget() + ->name('test') + ->ariaLabel('test') + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testAutofocus(): void + { + $result = Color::widget() + ->name('test') + ->autofocus() + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testTabIndex(): void + { + $result = Color::widget() + ->name('test') + ->tabIndex(5) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testValue(): void + { + $result = Color::widget() + ->name('test') + ->value('#123456') + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testValueNull(): void + { + $result = Color::widget() + ->name('test') + ->value(null) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testInvalidValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Color field requires a string or null value.'); + Color::widget()->name('test')->value(123)->render(); + } + + public function testEnrichFromValidationRules(): void + { + $result = Color::widget() + ->enrichFromValidationRules() + ->validationRulesEnricher(new RequiredValidationRulesEnricher()) + ->inputData(new InputData('color', validationRules: [['required']])) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testEnrichFromValidationRulesDisabled(): void + { + $result = Color::widget() + ->validationRulesEnricher(new RequiredValidationRulesEnricher()) + ->inputData(new InputData('color', validationRules: [['required']])) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testEnrichFromValidationRulesWithNullProcessResult(): void + { + $result = Color::widget() + ->enrichFromValidationRules() + ->validationRulesEnricher(new NullValidationRulesEnricher()) + ->inputData(new InputData('color')) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testEnrichmentInputAttributes(): void + { + $result = Color::widget() + ->enrichFromValidationRules() + ->validationRulesEnricher( + new StubValidationRulesEnricher(['inputAttributes' => ['data-test' => 1]]) + ) + ->inputData(new InputData('color')) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testInvalidClassesFromContainer(): void + { + $inputData = new InputData('color', validationErrors: ['Value cannot be blank.']); + + $result = Color::widget() + ->validClass('valid') + ->invalidClass('invalid') + ->inputData($inputData) + ->hideLabel() + ->render(); + + $expected = << + +
Value cannot be blank.
+ +HTML; + + $this->assertSame($expected, $result); + } + + public function testValidClassesFromContainer(): void + { + $inputData = new InputData('color', validationErrors: []); + + $result = Color::widget() + ->validClass('valid') + ->invalidClass('invalid') + ->inputData($inputData) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testInvalidClassesFromInput(): void + { + $inputData = new InputData('color', validationErrors: ['Value cannot be blank.']); + + $result = Color::widget() + ->inputValidClass('valid') + ->inputInvalidClass('invalid') + ->inputData($inputData) + ->hideLabel() + ->render(); + + $expected = << + +
Value cannot be blank.
+ +HTML; + + $this->assertSame($expected, $result); + } + + public function testValidClassesFromInput(): void + { + $inputData = new InputData('color', validationErrors: []); + + $result = Color::widget() + ->inputValidClass('valid') + ->inputInvalidClass('invalid') + ->inputData($inputData) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testDisabledFalse(): void + { + $result = Color::widget() + ->name('test') + ->disabled(false) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testReadonlyFalse(): void + { + $result = Color::widget() + ->name('test') + ->readonly(false) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testRequiredFalse(): void + { + $result = Color::widget() + ->name('test') + ->required(false) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testAutofocusFalse(): void + { + $result = Color::widget() + ->name('test') + ->autofocus(false) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testTabIndexNull(): void + { + $result = Color::widget() + ->name('test') + ->tabIndex(null) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testTabIndexNegative(): void + { + $result = Color::widget() + ->name('test') + ->tabIndex(-1) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testAriaLabelNull(): void + { + $result = Color::widget() + ->name('test') + ->ariaLabel(null) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testImmutability(): void + { + $field = Color::widget(); + + // Test that each method returns a different instance (kills CloneRemoval mutants) + $this->assertNotSame($field, $field->disabled()); + $this->assertNotSame($field, $field->readonly()); + $this->assertNotSame($field, $field->required()); + $this->assertNotSame($field, $field->ariaDescribedBy(null)); + $this->assertNotSame($field, $field->ariaLabel(null)); + $this->assertNotSame($field, $field->autofocus()); + $this->assertNotSame($field, $field->tabIndex(null)); + + // Test that original instance is not modified when chaining methods + $original = Color::widget()->name('original'); + $modified = $original->disabled()->readonly()->required()->autofocus(); + + $originalHtml = $original->hideLabel()->render(); + $modifiedHtml = $modified->hideLabel()->render(); + + $expectedOriginal = << + + + HTML; + + $expectedModified = << + + + HTML; + + $this->assertSame($expectedOriginal, $originalHtml); + $this->assertSame($expectedModified, $modifiedHtml); + } + + public function testEnrichmentAttributesMerge(): void + { + $result = Color::widget() + ->enrichFromValidationRules() + ->validationRulesEnricher( + new StubValidationRulesEnricher(['inputAttributes' => ['data-enriched' => 'from-validation']]) + ) + ->inputData(new InputData('color')) + ->disabled() + ->ariaLabel('test-label') + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testValidationEnrichmentDisabledByDefault(): void + { + $widget = Color::widget() + ->validationRulesEnricher(new RequiredValidationRulesEnricher()) + ->inputData(new InputData('color', validationRules: [['required']])); + + // Should not have 'required' attribute since enrichFromValidationRules() was not called + $result = $widget->hideLabel()->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testBeforeRenderEnrichmentCondition(): void + { + // Test the condition in beforeRender() that checks $this->enrichFromValidationRules + $widget = Color::widget() + ->enrichFromValidationRules() + ->inputData(new InputData('color', validationRules: [['required']])); + + // Without enricher, should not process rules + $result = $widget->hideLabel()->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testInvalidValueArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Color field requires a string or null value.'); + Color::widget()->name('test')->value([])->render(); + } + + public function testInvalidValueFloat(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Color field requires a string or null value.'); + Color::widget()->name('test')->value(3.14)->render(); + } + + public function testInvalidValueBoolean(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Color field requires a string or null value.'); + Color::widget()->name('test')->value(true)->render(); + } + + public function testInvalidValueObject(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Color field requires a string or null value.'); + Color::widget()->name('test')->value(new \stdClass())->render(); + } + + public function testValueEmptyString(): void + { + $result = Color::widget() + ->name('test') + ->value('') + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testAriaDescribedByFilteringBehavior(): void + { + // Test that array_filter in ariaDescribedBy correctly filters out null values + $result = Color::widget() + ->name('test') + ->ariaDescribedBy('valid1', null, '', 'valid2', null) + ->hideLabel() + ->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $result); + } + + public function testCustomErrorWithValidationClasses(): void + { + $inputData = new InputData('color', validationErrors: []); + + $result = Color::widget() + ->inputData($inputData) + ->validClass('valid-container') + ->invalidClass('invalid-container') + ->inputValidClass('valid-input') + ->inputInvalidClass('invalid-input') + ->error('Custom error message') + ->hideLabel() + ->render(); + + // With custom error, should show invalid classes even if no validation errors + $expected = << + +
Custom error message
+ + HTML; + + $this->assertSame($expected, $result); + } +} diff --git a/tests/PureField/FieldFactoryTest.php b/tests/PureField/FieldFactoryTest.php index b526b5c9..cc0f998c 100644 --- a/tests/PureField/FieldFactoryTest.php +++ b/tests/PureField/FieldFactoryTest.php @@ -234,6 +234,38 @@ public function testDateTimeLocalWithTheme(): void $this->assertSame($expected, $html); } + public function testColor(): void + { + $html = (new FieldFactory())->color()->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $html); + } + + public function testColorWithTheme(): void + { + ThemeContainer::initialize([ + 'test' => [ + 'containerTag' => 'span', + ], + ]); + + $html = (new FieldFactory('default'))->color(theme: 'test')->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $html); + } + public function testEmail(): void { $html = (new FieldFactory())->email()->render(); diff --git a/tests/PureField/FieldTest.php b/tests/PureField/FieldTest.php index 65acb951..663015bd 100644 --- a/tests/PureField/FieldTest.php +++ b/tests/PureField/FieldTest.php @@ -235,6 +235,38 @@ public function testDateTimeLocalWithTheme(): void $this->assertSame($expected, $html); } + public function testColor(): void + { + $html = Field::color()->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $html); + } + + public function testColorWithTheme(): void + { + ThemeContainer::initialize([ + 'test' => [ + 'containerTag' => 'span', + ], + ]); + + $html = ThemedField::color(theme: 'test')->render(); + + $expected = << + + + HTML; + + $this->assertSame($expected, $html); + } + public function testEmail(): void { $html = Field::email()->render(); diff --git a/themes-preview/template.php b/themes-preview/template.php index 8c25e77f..f77e6c0b 100644 --- a/themes-preview/template.php +++ b/themes-preview/template.php @@ -49,6 +49,8 @@ echo Field::email()->label('Email Field')->placeholder('Placeholder'); + echo Field::color()->label('Color Field')->value('#ff0000'); + echo Field::time()->label('Time Field'); echo Field::date()->label('Date Field');