diff --git a/README.md b/README.md index f2fa7fa..7668435 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,42 @@ 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. +### Textarea values + +`TextArea::create($label, $name, $value = '', $warningMessage = '')` accepts +**caller-sanitized content** for `$value`, consistent with the other controls. +It renders the supplied value without escaping, decoding, or double encoding. +The implementer must escape untrusted text before passing it to the component; +an unescaped `` can close the control and introduce markup. +Escaping flags, character encoding, and invalid-character handling remain the +implementer's choice. + +```php +use Eightfold\HtmlBuilder\Forms\TextArea; + +$value = "\nFirst line & second line\n"; +$escapedValue = htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + +TextArea::create('Notes', 'notes', $escapedValue, 'Add more detail.') + ->inputProps('rows 4', 'required required') + ->labelProps('class notes-label'); +``` + +Values are element text, not a `value` attribute. Spaces, tabs, and leading, +internal, and trailing LF newlines are preserved: rendering adds one extra +leading newline that HTML consumes. Native HTML parsing normalizes CR and CRLF +line endings to LF; browsers apply their native textarea submission rules. +`inputProps()` targets the textarea and supports native `rows`, `required`, +`readonly`, and `disabled` attributes. Generated `id` and `name` override +conflicting input properties; `id()` also updates the label and warning +associations. String and `PropertyInterface` warnings follow the shared guidance +rules above. + +The escaping call above is an application example, not a component default. +To display literal entity syntax such as `&`, escape that text too (for +example, supply `&`). Callers remain responsible for sanitizing all +values, labels, warning markup, additional content, and attribute values. + ### Select attributes and selection `Select::inputProps()` targets the `

Outside

']; + yield 'UTF-8 text' => ["Caf\u{00e9} \u{1f642}"]; + } + + #[Test] + #[DataProvider('values')] + public function caller_escaped_values_round_trip_without_double_encoding(string $value): void + { + $escaped = htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $control = TextArea::create('Notes', 'notes', $escaped); + $html = (string) $control; + $xpath = self::xpath($html); + $textareas = $xpath->query('//*[local-name()="textarea"]'); + parent::assertNotFalse($textareas); + parent::assertCount(1, $textareas, "Value must not create extra textareas.\nRendered HTML:\n" . $html); + $textarea = $textareas->item(0); + parent::assertInstanceOf(DomElement::class, $textarea); + parent::assertSame( + $value, + $textarea->textContent, + "Caller-escaped text must round-trip without trimming or double encoding.\nRendered HTML:\n" . $html + ); + parent::assertFalse($textarea->hasAttribute('value'), 'Textarea values belong in text content.'); + parent::assertSame( + 0, + $xpath->query('//*[local-name()="script" or local-name()="p"]')->length, + 'Caller-escaped markup-like text must not become document elements.' + ); + parent::assertStringContainsString( + '>' . "\n" . $escaped . '', + $html, + 'The supplied value must be rendered verbatim after the parser-consumed newline.' + ); + parent::assertSame($html, (string) $control, 'Repeated rendering must not transform the supplied value.'); + } + + /** @return iterable */ + public static function escapingOptions(): iterable + { + yield 'quotes' => [ENT_QUOTES | ENT_SUBSTITUTE]; + yield 'double quotes only' => [ENT_COMPAT | ENT_SUBSTITUTE]; + yield 'no quotes' => [ENT_NOQUOTES | ENT_SUBSTITUTE]; + yield 'HTML5 entities' => [ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5]; + } + + #[Test] + #[DataProvider('escapingOptions')] + public function caller_selected_escaping_options_are_preserved(int $flags): void + { + $value = ' & "quoted" and \'single\''; + $escaped = htmlspecialchars($value, $flags, 'UTF-8'); + $html = (string) TextArea::create('Notes', 'notes', $escaped); + parent::assertStringContainsString( + '>' . "\n" . $escaped . '', + $html, + 'TextArea must not impose its own encoding options on caller-escaped values.' + ); + parent::assertSame( + $value, + self::xpath($html)->query('//*[local-name()="textarea"]')->item(0)->textContent + ); + } + + #[Test] + public function supplied_content_is_not_automatically_sanitized(): void + { + $value = 'Literal source & "quotes"'; + parent::assertStringContainsString( + '>' . "\n" . $value . '', + (string) TextArea::create('Notes', 'notes', $value), + 'Sanitization belongs to the caller, consistently with the other controls.' + ); + } + + #[Test] + public function carriage_returns_follow_native_html_newline_normalization(): void + { + $xpath = self::xpath((string) TextArea::create('Notes', 'notes', "\r\nFirst\rSecond\r\n")); + parent::assertSame( + "\nFirst\nSecond\n", + $xpath->query('//*[local-name()="textarea"]')->item(0)->textContent, + 'HTML parsing normalizes CR and CRLF to LF without losing leading or trailing line breaks.' + ); + } + + #[Test] + public function native_properties_and_custom_identity_target_the_textarea(): void + { + $html = (string) TextArea::create('Notes', 'notes', 'A & B') + ->id('custom-notes') + ->inputProps( + 'id ignored', + 'name ignored', + 'rows 4', + 'required required', + 'readonly readonly', + 'disabled disabled' + )->wrapperProps('class notes-wrapper')->labelProps('class notes-label'); + $xpath = self::xpath($html); + $textarea = $xpath->query('//*[local-name()="textarea"]')->item(0); + parent::assertInstanceOf(DomElement::class, $textarea); + parent::assertSame('custom-notes', $textarea->getAttribute('id'), 'The explicit ID must win.'); + parent::assertSame('notes', $textarea->getAttribute('name'), 'The declared submitted name must win.'); + parent::assertSame('4', $textarea->getAttribute('rows')); + foreach (['required', 'readonly', 'disabled'] as $attribute) { + parent::assertTrue($textarea->hasAttribute($attribute), $attribute . ' must reach the textarea.'); + } + parent::assertFalse($textarea->hasAttribute('aria-invalid'), 'No warning must mean no generated invalid state.'); + parent::assertFalse($textarea->hasAttribute('aria-describedby'), 'No guidance must mean no generated references.'); + parent::assertSame( + 1, + $xpath->query('//*[local-name()="label" and @for="custom-notes" and @class="notes-label"]')->length, + 'The styled label must reference the custom control ID.' + ); + parent::assertSame( + 1, + $xpath->query('//*[local-name()="div" and @class="notes-wrapper"]')->length + ); + parent::assertSame( + 0, + $xpath->query('//*[local-name()="div" and (@rows or @required or @readonly or @disabled)]')->length, + 'Native control attributes must not leak onto the wrapper.' + ); + parent::assertStringContainsString('A & B', $html, 'Caller-escaped ampersands must remain unchanged.'); + } + + private static function xpath(string $html): XPath + { + return new XPath(HTMLDocument::createFromString( + 'Textarea test' + . $html . '' + )); + } +}