From 64dc5526b9f2f74c946ae5fa0e4becbea286eaeb Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Thu, 15 Oct 2026 13:07:17 -0400 Subject: [PATCH 1/3] Fix constructor-owned text and email values (#54) Exclude caller value declarations even when the initial value is empty; preserve Element empty-property semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 8 ++++ src/Forms/EmailInput.php | 10 +++- src/Forms/TextInput.php | 10 +++- tests/ControlContractTest.php | 86 +++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 tests/ControlContractTest.php diff --git a/README.md b/README.md index 0886f1f..c2a2384 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,14 @@ 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. + ### Textarea values `TextArea::create($label, $name, $value = '', $warningMessage = '')` accepts diff --git a/src/Forms/EmailInput.php b/src/Forms/EmailInput.php index 1419ce8..a758a3c 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' ); } 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..4e90a2f --- /dev/null +++ b/tests/ControlContractTest.php @@ -0,0 +1,86 @@ +}> */ + 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') + ); + } + + private static function xpath(string $html): XPath + { + return new XPath(HTMLDocument::createFromString( + 'Test' + . $html . '' + )); + } +} From 43528169449304e578a9769da367eabc0c4561e5 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Thu, 15 Oct 2026 13:07:46 -0400 Subject: [PATCH 2/3] Preserve generated EmailInput label associations (#57) Match other text-entry controls and add DOM relationship coverage across applicable controls with default and custom IDs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 5 +++ src/Forms/EmailInput.php | 8 ++--- tests/ControlContractTest.php | 58 +++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c2a2384..e0c7e74 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,11 @@ 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 a758a3c..4592d36 100644 --- a/src/Forms/EmailInput.php +++ b/src/Forms/EmailInput.php @@ -141,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/tests/ControlContractTest.php b/tests/ControlContractTest.php index 4e90a2f..6455df6 100644 --- a/tests/ControlContractTest.php +++ b/tests/ControlContractTest.php @@ -13,6 +13,9 @@ 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; final class ControlContractTest extends TestCase @@ -76,6 +79,61 @@ public function element_still_skips_empty_properties_without_clearing_previous_v ); } + /** @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( From 2f89114a39513fafdc747527b22f786a3b63abf4 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Thu, 15 Oct 2026 13:08:07 -0400 Subject: [PATCH 3/3] Cover Password in shared control guidance matrix (#56) Exercise all existing guidance cases using Password's constructor without an initial value. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- tests/ControlGuidanceTest.php | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e0c7e74..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 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(