diff --git a/README.md b/README.md index 0886f1f..5f6b493 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Output: ## Form control guidance -`TextInput`, `EmailInput`, `TextArea`, and all `Select` modes preserve help IDs +`TextInput`, `EmailInput`, `Password`, `TextArea`, and all `Select` modes preserve help IDs supplied through `inputProps('aria-describedby help-id')` when adding a warning. With a warning, the control sets `aria-invalid="true"` and appends `-warning` to `aria-describedby`, removing duplicate references while @@ -88,6 +88,19 @@ Overriding the control's `id()` also changes its generated warning ID. Assertion failures identify the relationship, control type and ID, and rendered HTML, alongside PHPUnit's named case and expected/actual diff. +### Text and email input values + +`TextInput` and `EmailInput` use the constructor's initial value, including an +empty string. Caller-supplied `value` properties in `inputProps()` are ignored; +an empty constructor value omits the attribute rather than retaining stale +caller content. Unrelated native input properties are preserved. Values remain +caller-sanitized: the components do not escape or encode them. + +Text-entry controls and dropdown selects keep each label's generated `for` +association in sync with the control's `id()`, overriding conflicting +`labelProps('for other')` properties while preserving unrelated label properties. +For radio and checkbox groups, `labelProps()` targets the legend instead. + ### Textarea values `TextArea::create($label, $name, $value = '', $warningMessage = '')` accepts diff --git a/src/Forms/EmailInput.php b/src/Forms/EmailInput.php index 1419ce8..4592d36 100644 --- a/src/Forms/EmailInput.php +++ b/src/Forms/EmailInput.php @@ -111,10 +111,16 @@ private function warningMessage(): string */ private function inputProperties(bool $noWarning): array { + $properties = array_values(array_filter( + $this->inputProperties, + static fn(string $property): bool => + strtolower(explode(' ', $property, 2)[0]) !== 'value' + )); + return ($noWarning) - ? $this->inputProperties + ? $properties : ControlProperties::withWarning( - $this->inputProperties, + $properties, $this->id . '-warning' ); } @@ -135,10 +141,10 @@ public function __toString(): string ]); return (string) Element::div( - Element::label($this->label)->props( - 'for ' . $this->id, - ...$this->labelProperties - ), + Element::label($this->label)->props(...[ + ...$this->labelProperties, + 'for ' . $this->id + ]), $input, ...[...$this->content, $warning] )->props(...$this->containerProperties); diff --git a/src/Forms/TextInput.php b/src/Forms/TextInput.php index 4b01b3a..c0ea48a 100644 --- a/src/Forms/TextInput.php +++ b/src/Forms/TextInput.php @@ -111,10 +111,16 @@ private function warningMessage(): string */ private function inputProperties(bool $noWarning): array { + $properties = array_values(array_filter( + $this->inputProperties, + static fn(string $property): bool => + strtolower(explode(' ', $property, 2)[0]) !== 'value' + )); + return ($noWarning) - ? $this->inputProperties + ? $properties : ControlProperties::withWarning( - $this->inputProperties, + $properties, $this->id . '-warning' ); } diff --git a/tests/ControlContractTest.php b/tests/ControlContractTest.php new file mode 100644 index 0000000..6455df6 --- /dev/null +++ b/tests/ControlContractTest.php @@ -0,0 +1,144 @@ +}> */ + public static function valueCases(): iterable + { + foreach (['text', 'email'] as $mode) { + foreach (['empty' => '', 'nonempty' => 'current@example.com', 'zero' => '0'] as $case => $value) { + foreach (['no warning' => '', 'warning' => 'Check the value.'] as $guidance => $warning) { + foreach ([ + 'single' => ['value stale@example.com'], + 'repeated' => ['value first@example.com', 'value second@example.com', 'value '], + 'mixed case' => ['VALUE stale@example.com', 'Value another@example.com'], + 'bare' => ['value', 'value stale@example.com'] + ] as $declaration => $properties) { + yield "$mode: $case, $guidance, $declaration" => [$mode, $value, $warning, $properties]; + } + } + } + } + } + + /** @param list $properties */ + #[Test] + #[DataProvider('valueCases')] + public function constructor_owns_value_including_empty( + string $mode, + string $value, + string $warning, + array $properties + ): void { + $control = match ($mode) { + 'text' => TextInput::create('Field', 'field', $value, $warning), + 'email' => EmailInput::create('Field', 'field', $value, $warning) + }; + $html = (string) $control->inputProps(...[ + ...$properties, + 'data-value preserved', + 'placeholder Enter a value', + 'required required' + ]); + $inputs = self::xpath($html)->query('//*[local-name()="input"]'); + parent::assertNotFalse($inputs); + parent::assertCount(1, $inputs, $html); + $input = $inputs->item(0); + parent::assertInstanceOf(DomElement::class, $input); + parent::assertSame($value === '' ? null : $value, $input->getAttribute('value'), $html); + parent::assertSame($value !== '', $input->hasAttribute('value'), $html); + parent::assertSame('preserved', $input->getAttribute('data-value'), $html); + parent::assertSame('Enter a value', $input->getAttribute('placeholder'), $html); + parent::assertTrue($input->hasAttribute('required'), $html); + } + + #[Test] + public function element_still_skips_empty_properties_without_clearing_previous_values(): void + { + parent::assertSame( + '', + (string) Element::input()->omitEndTag()->props('value existing', 'value ', 'value') + ); + } + + /** @return iterable */ + public static function labelCases(): iterable + { + foreach (['text', 'email', 'password', 'textarea', 'dropdown', 'multiple'] as $mode) { + foreach (['default' => null, 'overridden' => 'custom-field'] as $case => $id) { + yield "$mode: $case ID" => [$mode, $id]; + } + } + } + + #[Test] + #[DataProvider('labelCases')] + public function generated_label_association_overrides_caller_properties(string $mode, ?string $id): void + { + $control = match ($mode) { + 'text' => TextInput::create('Field', 'field'), + 'email' => EmailInput::create('Field', 'field'), + 'password' => Password::create('Field', 'field'), + 'textarea' => TextArea::create('Field', 'field'), + 'dropdown' => Select::create('Field', 'field', ['a' => 'A']), + 'multiple' => Select::create('Field', 'field', ['a' => 'A'])->multiple() + }; + if ($id !== null) { + $control->id($id); + } + $html = (string) $control->labelProps( + 'for other', + 'class field-label', + 'for another', + 'data-for preserved' + ); + $xpath = self::xpath($html); + $controls = $xpath->query( + '//*[local-name()="input" or local-name()="select" or local-name()="textarea"]' + ); + parent::assertNotFalse($controls); + parent::assertCount(1, $controls, $html); + $input = $controls->item(0); + parent::assertInstanceOf(DomElement::class, $input); + $controlId = $input->getAttribute('id'); + parent::assertSame($id ?? 'field', $controlId, $html); + $labels = $xpath->query('//*[local-name()="label"]'); + parent::assertNotFalse($labels); + parent::assertCount(1, $labels, $html); + $label = $labels->item(0); + parent::assertInstanceOf(DomElement::class, $label); + parent::assertSame($controlId, $label->getAttribute('for'), $html); + parent::assertSame('field-label', $label->getAttribute('class'), $html); + parent::assertSame('preserved', $label->getAttribute('data-for'), $html); + $targets = $xpath->query('//*[@id="' . $label->getAttribute('for') . '"]'); + parent::assertNotFalse($targets); + parent::assertCount(1, $targets, $html); + parent::assertSame($input, $targets->item(0), $html); + } + + private static function xpath(string $html): XPath + { + return new XPath(HTMLDocument::createFromString( + 'Test' + . $html . '' + )); + } +} diff --git a/tests/ControlGuidanceTest.php b/tests/ControlGuidanceTest.php index 8bf5c6c..af1fcd2 100644 --- a/tests/ControlGuidanceTest.php +++ b/tests/ControlGuidanceTest.php @@ -13,6 +13,7 @@ use Dom\XPath; use Eightfold\HtmlBuilder\Element; use Eightfold\HtmlBuilder\Forms\EmailInput; +use Eightfold\HtmlBuilder\Forms\Password; use Eightfold\HtmlBuilder\Forms\Select; use Eightfold\HtmlBuilder\Forms\TextArea; use Eightfold\HtmlBuilder\Forms\TextInput; @@ -23,7 +24,7 @@ final class ControlGuidanceTest extends TestCase /** @return iterable */ public static function controls(): iterable { - foreach (['text', 'email', 'textarea', 'dropdown', 'multiple', 'radio', 'checkbox'] as $mode) { + foreach (['text', 'email', 'password', 'textarea', 'dropdown', 'multiple', 'radio', 'checkbox'] as $mode) { yield $mode => [$mode]; } } @@ -145,6 +146,7 @@ private static function render( $control = match ($mode) { 'text' => TextInput::create('Field', 'field', '', $warning), 'email' => EmailInput::create('Field', 'field', '', $warning), + 'password' => Password::create('Field', 'field', $warning), 'textarea' => TextArea::create('Field', 'field', '', $warning), 'dropdown' => Select::create('Field', 'field', ['a' => 'A', 'b' => 'B'], [], $warning), 'multiple' => Select::create(