Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<control-id>-warning` to `aria-describedby`, removing duplicate references while
Expand All @@ -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
Expand Down
18 changes: 12 additions & 6 deletions src/Forms/EmailInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
}
Expand All @@ -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);
Expand Down
10 changes: 8 additions & 2 deletions src/Forms/TextInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
}
Expand Down
144 changes: 144 additions & 0 deletions tests/ControlContractTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

declare(strict_types=1);

namespace Eightfold\HtmlBuilder\Tests;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

use Dom\Element as DomElement;
use Dom\HTMLDocument;
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
{
/** @return iterable<string, array{string, string, string, list<string>}> */
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<string> $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(
'<input value="existing">',
(string) Element::input()->omitEndTag()->props('value existing', 'value ', 'value')
);
}

/** @return iterable<string, array{string, ?string}> */
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(
'<!doctype html><html><head><title>Test</title></head><body>'
. $html . '</body></html>'
));
}
}
4 changes: 3 additions & 1 deletion tests/ControlGuidanceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,7 +24,7 @@ final class ControlGuidanceTest extends TestCase
/** @return iterable<string, array{string}> */
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];
}
}
Expand Down Expand Up @@ -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(
Expand Down
Loading