summaryrefslogtreecommitdiffstats
path: root/vendor/ipl/html/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--vendor/ipl/html/src/Attribute.php328
-rw-r--r--vendor/ipl/html/src/Attributes.php518
-rw-r--r--vendor/ipl/html/src/BaseHtmlElement.php406
-rw-r--r--vendor/ipl/html/src/Common/MultipleAttribute.php70
-rw-r--r--vendor/ipl/html/src/Contract/FormElement.php132
-rw-r--r--vendor/ipl/html/src/Contract/FormElementDecorator.php40
-rw-r--r--vendor/ipl/html/src/Contract/FormSubmitElement.php13
-rw-r--r--vendor/ipl/html/src/Contract/ValueCandidates.php22
-rw-r--r--vendor/ipl/html/src/Contract/Wrappable.php45
-rw-r--r--vendor/ipl/html/src/DeferredText.php114
-rw-r--r--vendor/ipl/html/src/Error.php114
-rw-r--r--vendor/ipl/html/src/Form.php402
-rw-r--r--vendor/ipl/html/src/FormDecorator/CallbackDecorator.php41
-rw-r--r--vendor/ipl/html/src/FormDecorator/DdDtDecorator.php140
-rw-r--r--vendor/ipl/html/src/FormDecorator/DecoratorInterface.php19
-rw-r--r--vendor/ipl/html/src/FormDecorator/DivDecorator.php156
-rw-r--r--vendor/ipl/html/src/FormElement/BaseFormElement.php390
-rw-r--r--vendor/ipl/html/src/FormElement/ButtonElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/CheckboxElement.php124
-rw-r--r--vendor/ipl/html/src/FormElement/ColorElement.php16
-rw-r--r--vendor/ipl/html/src/FormElement/DateElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/FieldsetElement.php122
-rw-r--r--vendor/ipl/html/src/FormElement/FileElement.php414
-rw-r--r--vendor/ipl/html/src/FormElement/FormElements.php509
-rw-r--r--vendor/ipl/html/src/FormElement/HiddenElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/InputElement.php49
-rw-r--r--vendor/ipl/html/src/FormElement/LocalDateTimeElement.php53
-rw-r--r--vendor/ipl/html/src/FormElement/NumberElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/PasswordElement.php55
-rw-r--r--vendor/ipl/html/src/FormElement/RadioElement.php177
-rw-r--r--vendor/ipl/html/src/FormElement/RadioOption.php148
-rw-r--r--vendor/ipl/html/src/FormElement/SelectElement.php238
-rw-r--r--vendor/ipl/html/src/FormElement/SelectOption.php79
-rw-r--r--vendor/ipl/html/src/FormElement/SubmitButtonElement.php65
-rw-r--r--vendor/ipl/html/src/FormElement/SubmitElement.php50
-rw-r--r--vendor/ipl/html/src/FormElement/TextElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/TextareaElement.php24
-rw-r--r--vendor/ipl/html/src/FormElement/TimeElement.php8
-rw-r--r--vendor/ipl/html/src/FormattedString.php101
-rw-r--r--vendor/ipl/html/src/Html.php241
-rw-r--r--vendor/ipl/html/src/HtmlDocument.php607
-rw-r--r--vendor/ipl/html/src/HtmlElement.php43
-rw-r--r--vendor/ipl/html/src/HtmlString.php13
-rw-r--r--vendor/ipl/html/src/Table.php226
-rw-r--r--vendor/ipl/html/src/TemplateString.php175
-rw-r--r--vendor/ipl/html/src/Text.php116
-rw-r--r--vendor/ipl/html/src/ValidHtml.php17
47 files changed, 6660 insertions, 0 deletions
diff --git a/vendor/ipl/html/src/Attribute.php b/vendor/ipl/html/src/Attribute.php
new file mode 100644
index 0000000..c42485a
--- /dev/null
+++ b/vendor/ipl/html/src/Attribute.php
@@ -0,0 +1,328 @@
+<?php
+
+namespace ipl\Html;
+
+use InvalidArgumentException;
+
+/**
+ * HTML Attribute
+ *
+ * Every single HTML attribute is (or should be) an instance of this class.
+ * This guarantees that every attribute is safe and escaped correctly.
+ *
+ * Usually attributes are not instantiated directly, but created through an HTML
+ * element's exposed methods.
+ */
+class Attribute
+{
+ /** @var string */
+ protected $name;
+
+ /** @var string The separator used if value is an array */
+ protected $separator = ' ';
+
+ /** @var string|array|bool|null */
+ protected $value;
+
+ /**
+ * Create a new HTML attribute from the given name and value
+ *
+ * @param string $name The name of the attribute
+ * @param string|bool|array|null $value The value of the attribute
+ *
+ * @throws InvalidArgumentException If the name of the attribute contains special characters
+ */
+ public function __construct($name, $value = null)
+ {
+ $this->setName($name)->setValue($value);
+ }
+
+ /**
+ * Create a new HTML attribute from the given name and value
+ *
+ * @param string $name The name of the attribute
+ * @param string|bool|array|null $value The value of the attribute
+ *
+ * @return static
+ *
+ * @throws InvalidArgumentException If the name of the attribute contains special characters
+ */
+ public static function create($name, $value)
+ {
+ return new static($name, $value);
+ }
+
+ /**
+ * Create a new empty HTML attribute from the given name
+ *
+ * The value of the attribute will be null after construction.
+ *
+ * @param string $name The name of the attribute
+ *
+ * @return static
+ *
+ * @throws InvalidArgumentException If the name of the attribute contains special characters
+ */
+ public static function createEmpty($name)
+ {
+ return new static($name, null);
+ }
+
+ /**
+ * Escape the name of an attribute
+ *
+ * Makes sure that the name of an attribute really is a string.
+ *
+ * @param string $name
+ *
+ * @return string
+ */
+ public static function escapeName($name)
+ {
+ return (string) $name;
+ }
+
+ /**
+ * Escape the value of an attribute
+ *
+ * If the value is an array, returns the string representation
+ * of all array elements joined with the specified glue string.
+ *
+ * Values are escaped according to the HTML5 double-quoted attribute value syntax:
+ * {@link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 }.
+ *
+ * @param string|array $value
+ * @param string $glue Glue string to join elements if value is an array
+ *
+ * @return string
+ */
+ public static function escapeValue($value, $glue = ' ')
+ {
+ if (is_array($value)) {
+ $value = implode($glue, $value);
+ }
+
+ // We force double-quoted attribute value syntax so let's start by escaping double quotes
+ $value = str_replace('"', '&quot;', $value);
+
+ // In addition, values must not contain ambiguous ampersands
+ $value = preg_replace_callback(
+ '/&[0-9A-Z]+;/i',
+ function ($match) {
+ $subject = $match[0];
+
+ if (htmlspecialchars_decode($subject, ENT_COMPAT | ENT_HTML5) === $subject) {
+ // Ambiguous ampersand
+ return str_replace('&', '&amp;', $subject);
+ }
+
+ return $subject;
+ },
+ $value
+ );
+
+ return $value;
+ }
+
+ /**
+ * Get the name of the attribute
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set the name of the attribute
+ *
+ * @param string $name
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If the name contains special characters
+ */
+ protected function setName($name)
+ {
+ if (! preg_match('/^[a-z][a-z0-9:-]*$/i', $name)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Attribute names with special characters are not yet allowed: %s',
+ $name
+ ));
+ }
+
+ $this->name = $name;
+
+ return $this;
+ }
+
+ /**
+ * Get the separator by which multiple values are concatenated with
+ *
+ * @return string
+ */
+ public function getSeparator(): string
+ {
+ return $this->separator;
+ }
+
+ /**
+ * Set the separator to concatenate multiple values with
+ *
+ * @param string $separator
+ *
+ * @return $this
+ */
+ public function setSeparator(string $separator): self
+ {
+ $this->separator = $separator;
+
+ return $this;
+ }
+
+ /**
+ * Get the value of the attribute
+ *
+ * @return string|bool|array|null
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ /**
+ * Set the value of the attribute
+ *
+ * @param string|bool|array|null $value
+ *
+ * @return $this
+ */
+ public function setValue($value)
+ {
+ $this->value = $value;
+
+ return $this;
+ }
+
+ /**
+ * Add the given value(s) to the attribute
+ *
+ * @param string|array $value The value(s) to add
+ *
+ * @return $this
+ */
+ public function addValue($value)
+ {
+ $this->value = array_merge((array) $this->value, (array) $value);
+
+ return $this;
+ }
+
+ /**
+ * Remove the given value(s) from the attribute
+ *
+ * The current value is set to null if it matches the value to remove
+ * or is in the array of values to remove.
+ *
+ * If the current value is an array, all elements are removed which
+ * match the value(s) to remove.
+ *
+ * Does nothing if there is no such value to remove.
+ *
+ * @param string|array $value The value(s) to remove
+ *
+ * @return $this
+ */
+ public function removeValue($value)
+ {
+ $value = (array) $value;
+
+ $current = $this->getValue();
+
+ if (is_array($current)) {
+ $this->setValue(array_diff($current, $value));
+ } elseif (in_array($current, $value, true)) {
+ $this->setValue(null);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Test and return true if the attribute is boolean, false otherwise
+ *
+ * @return bool
+ */
+ public function isBoolean()
+ {
+ return is_bool($this->value);
+ }
+
+ /**
+ * Test and return true if the attribute is empty, false otherwise
+ *
+ * Null and the empty array will be considered empty.
+ *
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ return $this->value === null || $this->value === [];
+ }
+
+ /**
+ * Render the attribute to HTML
+ *
+ * If the value of the attribute is of type boolean, it will be rendered as
+ * {@link http://www.w3.org/TR/html5/infrastructure.html#boolean-attributes boolean attribute}.
+ * Note that in this case if the value of the attribute is false, the empty string will be returned.
+ *
+ * If the value of the attribute is null or an empty array,
+ * the empty string will be returned as well.
+ *
+ * Escaping of the attribute's value takes place automatically using {@link Attribute::escapeValue()}.
+ *
+ * @return string
+ */
+ public function render()
+ {
+ if ($this->isEmpty()) {
+ return '';
+ }
+
+ if ($this->isBoolean()) {
+ if ($this->value) {
+ return $this->renderName();
+ }
+
+ return '';
+ } else {
+ return sprintf(
+ '%s="%s"',
+ $this->renderName(),
+ $this->renderValue()
+ );
+ }
+ }
+
+ /**
+ * Render the name of the attribute to HTML
+ *
+ * @return string
+ */
+ public function renderName()
+ {
+ return static::escapeName($this->name);
+ }
+
+ /**
+ * Render the value of the attribute to HTML
+ *
+ * @return string
+ */
+ public function renderValue()
+ {
+ return static::escapeValue($this->value, $this->separator);
+ }
+}
diff --git a/vendor/ipl/html/src/Attributes.php b/vendor/ipl/html/src/Attributes.php
new file mode 100644
index 0000000..8df9bbd
--- /dev/null
+++ b/vendor/ipl/html/src/Attributes.php
@@ -0,0 +1,518 @@
+<?php
+
+namespace ipl\Html;
+
+use ArrayAccess;
+use ArrayIterator;
+use InvalidArgumentException;
+use IteratorAggregate;
+use Traversable;
+
+use function ipl\Stdlib\get_php_type;
+
+/**
+ * HTML attributes
+ *
+ * HTML attributes provide additional information about HTML elements, that configure the elements or adjust their
+ * behavior in various ways.
+ *
+ * Attributes usually come in name-value pairs and are rendered as name="value".
+ */
+class Attributes implements ArrayAccess, IteratorAggregate
+{
+ /** @var Attribute[] */
+ protected $attributes = [];
+
+ /** @var callable[] */
+ protected $callbacks = [];
+
+ /** @var string */
+ protected $prefix = '';
+
+ /** @var callable[] */
+ protected $setterCallbacks = [];
+
+ /**
+ * Create new HTML attributes
+ *
+ * @param array $attributes
+ */
+ public function __construct(array $attributes = null)
+ {
+ if (empty($attributes)) {
+ return;
+ }
+
+ foreach ($attributes as $key => $value) {
+ if ($value instanceof Attribute) {
+ $this->addAttribute($value);
+ } elseif (is_string($key)) {
+ $this->add($key, $value);
+ } elseif (is_array($value) && count($value) === 2) {
+ $this->add(array_shift($value), array_shift($value));
+ }
+ }
+ }
+
+ /**
+ * Create new HTML attributes
+ *
+ * @param array $attributes
+ *
+ * @return static
+ */
+ public static function create(array $attributes = null)
+ {
+ return new static($attributes);
+ }
+
+ /**
+ * Ensure that the given attributes of mixed type are converted to an instance of attributes
+ *
+ * The conversion procedure is as follows:
+ *
+ * If the given attributes is already an instance of Attributes, returns the very same element.
+ * If the attributes are given as an array of attribute name-value pairs, they are used to
+ * construct and return a new Attributes instance.
+ * If the attributes are null, an empty new instance of Attributes is returned.
+ *
+ * @param array|static|null $attributes
+ *
+ * @return static
+ *
+ * @throws InvalidArgumentException In case the given attributes are of an unsupported type
+ */
+ public static function wantAttributes($attributes)
+ {
+ if ($attributes instanceof self) {
+ return $attributes;
+ }
+
+ if (is_array($attributes)) {
+ return new static($attributes);
+ }
+
+ if ($attributes === null) {
+ return new static();
+ }
+
+ throw new InvalidArgumentException(sprintf(
+ 'Attributes instance, array or null expected. Got %s instead.',
+ get_php_type($attributes)
+ ));
+ }
+
+ /**
+ * Get the collection of attributes as array
+ *
+ * @return Attribute[]
+ */
+ public function getAttributes()
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * Merge the given attributes
+ *
+ * @param Attributes $attributes
+ *
+ * @return $this
+ */
+ public function merge(Attributes $attributes)
+ {
+ foreach ($attributes as $attribute) {
+ $this->addAttribute($attribute);
+ }
+
+ foreach ($attributes->callbacks as $name => $getter) {
+ $setter = null;
+ if (isset($attributes->setterCallbacks[$name])) {
+ $setter = $attributes->setterCallbacks[$name];
+ }
+
+ $this->registerAttributeCallback($name, $getter, $setter);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return true if the attribute with the given name exists, false otherwise
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function has($name)
+ {
+ return array_key_exists($name, $this->attributes);
+ }
+
+ /**
+ * Get the attribute with the given name
+ *
+ * If the attribute does not yet exist, it is automatically created and registered to this Attributes instance.
+ *
+ * @param string $name
+ *
+ * @return Attribute
+ *
+ * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters
+ */
+ public function get($name)
+ {
+ if (! $this->has($name)) {
+ $this->attributes[$name] = Attribute::createEmpty($name);
+ }
+
+ return $this->attributes[$name];
+ }
+
+ /**
+ * Set the given attribute(s)
+ *
+ * If the attribute with the given name already exists, it gets overridden.
+ *
+ * @param string|array|Attribute|self $attribute The attribute(s) to add
+ * @param string|bool|array $value The value of the attribute
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If the attribute name contains special characters
+ */
+ public function set($attribute, $value = null)
+ {
+ if ($attribute instanceof self) {
+ foreach ($attribute as $a) {
+ $this->setAttribute($a);
+ }
+
+ return $this;
+ }
+
+ if ($attribute instanceof Attribute) {
+ $this->setAttribute($attribute);
+
+ return $this;
+ }
+
+ if (is_array($attribute)) {
+ foreach ($attribute as $name => $value) {
+ $this->set($name, $value);
+ }
+
+ return $this;
+ }
+
+ if (array_key_exists($attribute, $this->setterCallbacks)) {
+ $callback = $this->setterCallbacks[$attribute];
+
+ $callback($value);
+
+ return $this;
+ }
+
+ $this->attributes[$attribute] = Attribute::create($attribute, $value);
+
+ return $this;
+ }
+
+ /**
+ * Add the given attribute(s)
+ *
+ * If an attribute with the same name already exists, the attribute's value will be added to the current value of
+ * the attribute.
+ *
+ * @param string|array|Attribute|self $attribute The attribute(s) to add
+ * @param string|bool|array $value The value of the attribute
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters
+ */
+ public function add($attribute, $value = null)
+ {
+ if ($attribute === null) {
+ return $this;
+ }
+
+ if ($attribute instanceof self) {
+ foreach ($attribute as $attr) {
+ $this->add($attr);
+ }
+
+ return $this;
+ }
+
+ if (is_array($attribute)) {
+ foreach ($attribute as $name => $value) {
+ $this->add($name, $value);
+ }
+
+ return $this;
+ }
+
+ if ($attribute instanceof Attribute) {
+ $this->addAttribute($attribute);
+
+ return $this;
+ }
+
+ if (array_key_exists($attribute, $this->setterCallbacks)) {
+ $callback = $this->setterCallbacks[$attribute];
+
+ $callback($value);
+
+ return $this;
+ }
+
+ if (! array_key_exists($attribute, $this->attributes)) {
+ $this->attributes[$attribute] = Attribute::create($attribute, $value);
+ } else {
+ $this->attributes[$attribute]->addValue($value);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Remove the attribute with the given name or remove the given value from the attribute
+ *
+ * @param string $name The name of the attribute
+ * @param null|string|array $value The value to remove if specified
+ *
+ * @return ?Attribute The removed or changed attribute, if any, otherwise null
+ */
+ public function remove($name, $value = null): ?Attribute
+ {
+ if (! $this->has($name)) {
+ return null;
+ }
+
+ $attribute = $this->attributes[$name];
+
+ if ($value === null) {
+ unset($this->attributes[$name]);
+ } else {
+ $attribute->removeValue($value);
+ }
+
+ return $attribute;
+ }
+
+ /**
+ * Set the specified attribute
+ *
+ * @param Attribute $attribute
+ *
+ * @return $this
+ */
+ public function setAttribute(Attribute $attribute)
+ {
+ $this->attributes[$attribute->getName()] = $attribute;
+
+ return $this;
+ }
+
+ /**
+ * Add the specified attribute
+ *
+ * If an attribute with the same name already exists, the given attribute's value
+ * will be added to the current value of the attribute.
+ *
+ * @param Attribute $attribute
+ *
+ * @return $this
+ */
+ public function addAttribute(Attribute $attribute)
+ {
+ $name = $attribute->getName();
+
+ if ($this->has($name)) {
+ $this->attributes[$name]->addValue($attribute->getValue());
+ } else {
+ $this->attributes[$name] = $attribute;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get the attributes name prefix
+ *
+ * @return string|null
+ */
+ public function getPrefix()
+ {
+ return $this->prefix;
+ }
+
+ /**
+ * Set the attributes name prefix
+ *
+ * @param string $prefix
+ *
+ * @return $this
+ */
+ public function setPrefix($prefix)
+ {
+ $this->prefix = $prefix;
+
+ return $this;
+ }
+
+ /**
+ * Register callback for an attribute
+ *
+ * @param string $name Name of the attribute to register the callback for
+ * @param ?callable $callback Callback to call when retrieving the attribute
+ * @param ?callable $setterCallback Callback to call when setting the attribute
+ *
+ * @return $this
+ */
+ public function registerAttributeCallback(string $name, ?callable $callback, ?callable $setterCallback = null): self
+ {
+ if ($callback !== null) {
+ $this->callbacks[$name] = $callback;
+ }
+
+ if ($setterCallback !== null) {
+ $this->setterCallbacks[$name] = $setterCallback;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Render attributes to HTML
+ *
+ * If the value of an attribute is of type boolean, it will be rendered as
+ * {@link http://www.w3.org/TR/html5/infrastructure.html#boolean-attributes boolean attribute}.
+ *
+ * If the value of an attribute is null, it will be skipped.
+ *
+ * HTML-escaping of the attributes' values takes place automatically using {@link Attribute::escapeValue()}.
+ *
+ * @return string
+ *
+ * @throws InvalidArgumentException If the result of a callback is invalid
+ */
+ public function render()
+ {
+ $attributes = $this->attributes;
+ foreach ($this->callbacks as $name => $callback) {
+ $attribute = call_user_func($callback);
+ if ($attribute instanceof Attribute) {
+ if ($attribute->isEmpty()) {
+ continue;
+ }
+ } elseif ($attribute === null) {
+ continue;
+ } elseif (is_scalar($attribute)) {
+ $attribute = Attribute::create($name, $attribute);
+ } else {
+ throw new InvalidArgumentException(sprintf(
+ 'A registered attribute callback must return a scalar, null'
+ . ' or an Attribute, got a %s',
+ get_php_type($attribute)
+ ));
+ }
+
+ $name = $attribute->getName();
+ if (isset($attributes[$name])) {
+ $attributes[$name] = clone $attributes[$name];
+ $attributes[$name]->addValue($attribute->getValue());
+ } else {
+ $attributes[$name] = $attribute;
+ }
+ }
+
+ $parts = [];
+ foreach ($attributes as $attribute) {
+ if ($attribute->isEmpty()) {
+ continue;
+ }
+
+ $parts[] = $attribute->render();
+ }
+
+ if (empty($parts)) {
+ return '';
+ }
+
+ $separator = ' ' . $this->getPrefix();
+
+ return $separator . implode($separator, $parts);
+ }
+
+ /**
+ * Get whether the attribute with the given name exists
+ *
+ * @param string $name Name of the attribute
+ *
+ * @return bool
+ */
+ public function offsetExists($name): bool
+ {
+ return $this->has($name);
+ }
+
+ /**
+ * Get the attribute with the given name
+ *
+ * If the attribute does not yet exist, it is automatically created and registered to this Attributes instance.
+ *
+ * @param string $name Name of the attribute
+ *
+ * @return Attribute
+ *
+ * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters
+ */
+ public function offsetGet($name): Attribute
+ {
+ return $this->get($name);
+ }
+
+ /**
+ * Set the given attribute
+ *
+ * If the attribute with the given name already exists, it gets overridden.
+ *
+ * @param string $name Name of the attribute
+ * @param mixed $value Value of the attribute
+ *
+ * @throws InvalidArgumentException If the attribute name contains special characters
+ */
+ public function offsetSet($name, $value): void
+ {
+ $this->set($name, $value);
+ }
+
+ /**
+ * Remove the attribute with the given name
+ *
+ * @param string $name Name of the attribute
+ */
+ public function offsetUnset($name): void
+ {
+ $this->remove($name);
+ }
+
+ /**
+ * Get an iterator for traversing the attributes
+ *
+ * @return Attribute[]|ArrayIterator
+ */
+ public function getIterator(): Traversable
+ {
+ return new ArrayIterator($this->attributes);
+ }
+
+ public function __clone()
+ {
+ foreach ($this->attributes as &$attribute) {
+ $attribute = clone $attribute;
+ }
+ }
+}
diff --git a/vendor/ipl/html/src/BaseHtmlElement.php b/vendor/ipl/html/src/BaseHtmlElement.php
new file mode 100644
index 0000000..5dc01ce
--- /dev/null
+++ b/vendor/ipl/html/src/BaseHtmlElement.php
@@ -0,0 +1,406 @@
+<?php
+
+namespace ipl\Html;
+
+use InvalidArgumentException;
+use RuntimeException;
+
+/**
+ * Base class for HTML elements
+ *
+ * Extend this class in order to provide concrete HTML elements or series of HTML elements, e.g. widgets.
+ * When extending this class you should provide the element's tag with {@link $tag}. Setting default attributes is
+ * possible via {@link $defaultAttributes}. And the content of the element is provided in {@link assemble()}.
+ *
+ * # Example Usage
+ * ```
+ * namespace Acme\Widgets;
+ *
+ * use ipl\Html\BaseHtmlElement;
+ *
+ * class Dashboard extends BaseHtmlElement
+ * {
+ * protected $defaultAttributes = ['class' => 'acme-dashboard'];
+ *
+ * protected $tag = 'div';
+ *
+ * protected function assemble()
+ * {
+ * // ...
+ * $this->add($content);
+ * }
+ * }
+ * ```
+ */
+abstract class BaseHtmlElement extends HtmlDocument
+{
+ /**
+ * List of void elements which must not contain end tags or content
+ *
+ * If {@link $isVoid} is null, this property should be used to decide whether the content and end tag has to be
+ * rendered.
+ *
+ * @var array
+ *
+ * @see https://www.w3.org/TR/html5/syntax.html#void-elements
+ */
+ protected static $voidElements = [
+ 'area' => 1,
+ 'base' => 1,
+ 'br' => 1,
+ 'col' => 1,
+ 'embed' => 1,
+ 'hr' => 1,
+ 'img' => 1,
+ 'input' => 1,
+ 'link' => 1,
+ 'meta' => 1,
+ 'param' => 1,
+ 'source' => 1,
+ 'track' => 1,
+ 'wbr' => 1
+ ];
+
+ /** @var Attributes */
+ protected $attributes;
+
+ /** @var bool Whether possible attribute callbacks have been registered */
+ protected $attributeCallbacksRegistered = false;
+
+ /** @var bool|null Whether the element is void. If null, void check should use {@link $voidElements} */
+ protected $isVoid;
+
+ /** @var array<string, mixed> You may want to set default attributes when extending this class */
+ protected $defaultAttributes;
+
+ /** @var string Tag of element. Set this property in order to provide the element's tag when extending this class */
+ protected $tag;
+
+ /**
+ * Get the attributes of the element
+ *
+ * @return Attributes
+ */
+ public function getAttributes()
+ {
+ if ($this->attributes === null) {
+ $default = $this->getDefaultAttributes();
+ if (empty($default)) {
+ $this->attributes = new Attributes();
+ } else {
+ $this->attributes = Attributes::wantAttributes($default);
+ }
+
+ $this->ensureAttributeCallbacksRegistered();
+ }
+
+ return $this->attributes;
+ }
+
+ /**
+ * Set the attributes of the element
+ *
+ * @param Attributes|array|null $attributes
+ *
+ * @return $this
+ */
+ public function setAttributes($attributes)
+ {
+ $this->attributes = Attributes::wantAttributes($attributes);
+
+ $this->attributeCallbacksRegistered = false;
+ $this->ensureAttributeCallbacksRegistered();
+
+ return $this;
+ }
+
+ /**
+ * Return true if the attribute with the given name exists, false otherwise
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function hasAttribute(string $name): bool
+ {
+ return $this->getAttributes()->has($name);
+ }
+
+ /**
+ * Get the attribute with the given name
+ *
+ * If the attribute does not already exist, an empty one is automatically created and added to the attributes.
+ *
+ * @param string $name
+ *
+ * @return Attribute
+ *
+ * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters
+ */
+ public function getAttribute(string $name): Attribute
+ {
+ return $this->getAttributes()->get($name);
+ }
+
+ /**
+ * Set the attribute with the given name and value
+ *
+ * If the attribute with the given name already exists, it gets overridden.
+ *
+ * @param string $name The name of the attribute
+ * @param string|bool|array $value The value of the attribute
+ *
+ * @return $this
+ */
+ public function setAttribute($name, $value)
+ {
+ $this->getAttributes()->set($name, $value);
+
+ return $this;
+ }
+
+ /**
+ * Remove the attribute with the given name or remove the given value from the attribute
+ *
+ * @param string $name The name of the attribute
+ * @param null|string|array $value The value to remove if specified
+ *
+ * @return ?Attribute The removed or changed attribute, if any, otherwise null
+ */
+ public function removeAttribute(string $name, $value = null): ?Attribute
+ {
+ return $this->getAttributes()->remove($name, $value);
+ }
+
+ /**
+ * Add the given attributes
+ *
+ * @param Attributes|array $attributes
+ *
+ * @return $this
+ */
+ public function addAttributes($attributes)
+ {
+ $this->getAttributes()->add($attributes);
+
+ return $this;
+ }
+
+ /**
+ * Get the default attributes of the element
+ *
+ * @return array
+ */
+ public function getDefaultAttributes()
+ {
+ return $this->defaultAttributes;
+ }
+
+ /**
+ * Get the tag of the element
+ *
+ * Since HTML Elements must have a tag, this method throws an exception if the element does not have a tag.
+ *
+ * @return string
+ *
+ * @throws RuntimeException If the element does not have a tag
+ */
+ final public function getTag()
+ {
+ $tag = $this->tag();
+
+ if (! $tag) {
+ throw new RuntimeException('Element must have a tag');
+ }
+
+ return $tag;
+ }
+
+ /**
+ * Set the tag of the element
+ *
+ * @param string $tag
+ *
+ * @return $this
+ */
+ public function setTag($tag)
+ {
+ $this->tag = $tag;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the element is void
+ *
+ * The default void detection which checks whether the element's tag is in the list of void elements according to
+ * https://www.w3.org/TR/html5/syntax.html#void-elements.
+ *
+ * If you want to override this behavior, use {@link setVoid()}.
+ *
+ * @return bool
+ */
+ public function isVoid()
+ {
+ if ($this->isVoid !== null) {
+ return $this->isVoid;
+ }
+
+ $tag = $this->getTag();
+
+ return isset(self::$voidElements[$tag]);
+ }
+
+ /**
+ * Set whether the element is void
+ *
+ * You may use this method to override the default void detection which checks whether the element's tag is in the
+ * list of void elements according to https://www.w3.org/TR/html5/syntax.html#void-elements.
+ *
+ * If you specify null, void detection is reset to its default behavior.
+ *
+ * @param bool|null $void
+ *
+ * @return $this
+ */
+ public function setVoid($void = true)
+ {
+ $this->isVoid = $void === null ?: (bool) $void;
+
+ return $this;
+ }
+
+ /**
+ * Ensure that possible attribute callbacks have been registered
+ *
+ * Note that this method is automatically called in {@link getAttributes()} and {@link setAttributes()}.
+ *
+ * @return $this
+ */
+ public function ensureAttributeCallbacksRegistered()
+ {
+ if (! $this->attributeCallbacksRegistered) {
+ $this->attributeCallbacksRegistered = true;
+ $this->registerAttributeCallbacks($this->attributes);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Render the content of the element to HTML
+ *
+ * @return string
+ */
+ public function renderContent()
+ {
+ return parent::renderUnwrapped();
+ }
+
+ /**
+ * Get whether the closing tag should be rendered
+ *
+ * @return bool True for void elements, false otherwise
+ */
+ public function wantsClosingTag()
+ {
+ // TODO: There is more. SVG and MathML namespaces
+ return ! $this->isVoid();
+ }
+
+ /**
+ * Use this element to wrap the given document
+ *
+ * @param HtmlDocument $document
+ *
+ * @return $this
+ */
+ public function wrap(HtmlDocument $document)
+ {
+ $document->addWrapper($this);
+
+ return $this;
+ }
+
+ /**
+ * Internal method for accessing the tag
+ *
+ * You may override this method in order to provide the tag dynamically
+ *
+ * @return string
+ */
+ protected function tag()
+ {
+ return $this->tag;
+ }
+
+ /**
+ * Register attribute callbacks
+ *
+ * Override this method in order to register attribute callbacks in concrete classes.
+ */
+ protected function registerAttributeCallbacks(Attributes $attributes)
+ {
+ }
+
+ public function addHtml(ValidHtml ...$content)
+ {
+ $this->ensureAssembled();
+
+ parent::addHtml(...$content);
+
+ return $this;
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @throws RuntimeException If the element does not have a tag or is void but has content
+ */
+ public function renderUnwrapped()
+ {
+ $this->ensureAssembled();
+
+ $tag = $this->getTag();
+ $content = $this->renderContent();
+ $attributes = $this->getAttributes()->render();
+
+ if (strlen($this->contentSeparator)) {
+ $length = strlen($content);
+ if ($length > 0) {
+ if ($content[0] === '<') {
+ $content = $this->contentSeparator . $content;
+ $length++;
+ }
+ if ($content[$length - 1] === '>') {
+ $content .= $this->contentSeparator;
+ }
+ }
+ }
+
+ if (! $this->wantsClosingTag()) {
+ if (strlen($content)) {
+ throw new RuntimeException('Void elements must not have content');
+ }
+
+ return sprintf('<%s%s />', $tag, $attributes);
+ }
+
+ return sprintf(
+ '<%s%s>%s</%s>',
+ $tag,
+ $attributes,
+ $content,
+ $tag
+ );
+ }
+
+ public function __clone()
+ {
+ parent::__clone();
+
+ if ($this->attributes !== null) {
+ $this->attributes = clone $this->attributes;
+ }
+ }
+}
diff --git a/vendor/ipl/html/src/Common/MultipleAttribute.php b/vendor/ipl/html/src/Common/MultipleAttribute.php
new file mode 100644
index 0000000..00a68b2
--- /dev/null
+++ b/vendor/ipl/html/src/Common/MultipleAttribute.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace ipl\Html\Common;
+
+use ipl\Html\Attributes;
+use ipl\Html\Contract\FormElement;
+
+/**
+ * Trait for form elements that can have the `multiple` attribute
+ *
+ * **Example usage:**
+ *
+ * ```
+ * namespace ipl\Html\FormElement;
+ *
+ * use ipl\Html\Common\MultipleAttribute;
+ *
+ * class SelectElement extends BaseFormElement
+ * {
+ * protected function registerAttributeCallbacks(Attributes $attributes)
+ * {
+ * // ...
+ * $this->registerMultipleAttributeCallback($attributes);
+ * }
+ * }
+ * ```
+ */
+trait MultipleAttribute
+{
+ /** @var bool Whether the attribute `multiple` is set to `true` */
+ protected $multiple = false;
+
+ /**
+ * Get whether the attribute `multiple` is set to `true`
+ *
+ * @return bool
+ */
+ public function isMultiple(): bool
+ {
+ return $this->multiple;
+ }
+
+ /**
+ * Set the `multiple` attribute
+ *
+ * @param bool $multiple
+ *
+ * @return $this
+ */
+ public function setMultiple(bool $multiple): self
+ {
+ $this->multiple = $multiple;
+
+ return $this;
+ }
+
+ /**
+ * Register the callback for `multiple` Attribute
+ *
+ * @param Attributes $attributes
+ */
+ protected function registerMultipleAttributeCallback(Attributes $attributes): void
+ {
+ $attributes->registerAttributeCallback(
+ 'multiple',
+ [$this, 'isMultiple'],
+ [$this, 'setMultiple']
+ );
+ }
+}
diff --git a/vendor/ipl/html/src/Contract/FormElement.php b/vendor/ipl/html/src/Contract/FormElement.php
new file mode 100644
index 0000000..1467c50
--- /dev/null
+++ b/vendor/ipl/html/src/Contract/FormElement.php
@@ -0,0 +1,132 @@
+<?php
+
+namespace ipl\Html\Contract;
+
+use ipl\Html\Attributes;
+use ipl\Html\Form;
+
+/**
+ * Representation of form elements
+ */
+interface FormElement extends Wrappable
+{
+ /**
+ * Get the attributes or options of the element
+ *
+ * @return Attributes
+ */
+ public function getAttributes();
+
+ /**
+ * Add attributes or options to the form element
+ *
+ * @param iterable $attributes
+ *
+ * @return $this
+ */
+ public function addAttributes($attributes);
+
+ /**
+ * Get the description for the element, if any
+ *
+ * @return string|null
+ */
+ public function getDescription();
+
+ /**
+ * Get the label for the element, if any
+ *
+ * @return string|null
+ */
+ public function getLabel();
+
+ /**
+ * Get the validation error messages
+ *
+ * @return array
+ */
+ public function getMessages();
+
+ /**
+ * Add a validation error message
+ *
+ * @param string $message
+ *
+ * @return $this
+ */
+ public function addMessage($message);
+
+ /**
+ * Get the name of the element
+ *
+ * @return string
+ */
+ public function getName();
+
+ /**
+ * Get whether the element has a value
+ *
+ * @return bool False if the element's value is null, the empty string or the empty array; true otherwise
+ */
+ public function hasValue();
+
+ /**
+ * Get the value of the element
+ *
+ * @return mixed
+ */
+ public function getValue();
+
+ /**
+ * Set the value of the element
+ *
+ * @param mixed $value
+ *
+ * @return $this
+ */
+ public function setValue($value);
+
+ /**
+ * Get whether the element has been validated
+ *
+ * @return bool
+ */
+ public function hasBeenValidated();
+
+ /**
+ * Get whether the element is ignored
+ *
+ * @return bool
+ */
+ public function isIgnored();
+
+ /**
+ * Get whether the element is required
+ *
+ * @return bool
+ */
+ public function isRequired();
+
+ /**
+ * Get whether the element is valid
+ *
+ * @return bool
+ */
+ public function isValid();
+
+ /**
+ * Validate the element
+ *
+ * @return $this
+ */
+ public function validate();
+
+ /**
+ * Handler which is called after this element has been registered
+ *
+ * @param Form $form
+ *
+ * @return void
+ */
+ public function onRegistered(Form $form);
+}
diff --git a/vendor/ipl/html/src/Contract/FormElementDecorator.php b/vendor/ipl/html/src/Contract/FormElementDecorator.php
new file mode 100644
index 0000000..ca770ea
--- /dev/null
+++ b/vendor/ipl/html/src/Contract/FormElementDecorator.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace ipl\Html\Contract;
+
+use ipl\Html\ValidHtml;
+
+/**
+ * Representation of form element decorators
+ */
+interface FormElementDecorator extends ValidHtml
+{
+ /**
+ * Decorate the given form element
+ *
+ * Decoration works by calling `prependWrapper()` on the form element,
+ * passing a clone of the decorator. Hidden elements are to be ignored.
+ *
+ * **Reference implementation:**
+ *
+ * ```php
+ * public function decorate(FormElement $formElement)
+ * {
+ * if ($formElement instanceof HiddenElement) {
+ * return;
+ * }
+ *
+ * $decorator = clone $this;
+ *
+ * // Wrapper logic can be overridden to adjust or propagate the decorator.
+ * // So here we make sure that a yet unbound decorator is passed.
+ * $formElement->prependWrapper($decorator);
+ *
+ * ...
+ * }
+ * ```
+ *
+ * @param FormElement $formElement
+ */
+ public function decorate(FormElement $formElement);
+}
diff --git a/vendor/ipl/html/src/Contract/FormSubmitElement.php b/vendor/ipl/html/src/Contract/FormSubmitElement.php
new file mode 100644
index 0000000..4909df8
--- /dev/null
+++ b/vendor/ipl/html/src/Contract/FormSubmitElement.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace ipl\Html\Contract;
+
+interface FormSubmitElement extends FormElement
+{
+ /**
+ * Get whether the element has been pressed
+ *
+ * @return bool
+ */
+ public function hasBeenPressed();
+}
diff --git a/vendor/ipl/html/src/Contract/ValueCandidates.php b/vendor/ipl/html/src/Contract/ValueCandidates.php
new file mode 100644
index 0000000..1cd2d6f
--- /dev/null
+++ b/vendor/ipl/html/src/Contract/ValueCandidates.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace ipl\Html\Contract;
+
+interface ValueCandidates
+{
+ /**
+ * Get value candidates of this element
+ *
+ * @return array<int, mixed>
+ */
+ public function getValueCandidates();
+
+ /**
+ * Set value candidates of this element
+ *
+ * @param array<int, mixed> $values
+ *
+ * @return $this
+ */
+ public function setValueCandidates(array $values);
+}
diff --git a/vendor/ipl/html/src/Contract/Wrappable.php b/vendor/ipl/html/src/Contract/Wrappable.php
new file mode 100644
index 0000000..c8cf924
--- /dev/null
+++ b/vendor/ipl/html/src/Contract/Wrappable.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace ipl\Html\Contract;
+
+use ipl\Html\ValidHtml;
+
+/**
+ * Representation of wrappable elements
+ */
+interface Wrappable extends ValidHtml
+{
+ /**
+ * Get the wrapper, if any
+ *
+ * @return Wrappable|null
+ */
+ public function getWrapper();
+
+ /**
+ * Set the wrapper
+ *
+ * @param Wrappable $wrapper
+ *
+ * @return $this
+ */
+ public function setWrapper(Wrappable $wrapper);
+
+ /**
+ * Add a wrapper
+ *
+ * @param Wrappable $wrapper
+ *
+ * @return $this
+ */
+ public function addWrapper(Wrappable $wrapper);
+
+ /**
+ * Prepend a wrapper
+ *
+ * @param Wrappable $wrapper
+ *
+ * @return $this
+ */
+ public function prependWrapper(Wrappable $wrapper);
+}
diff --git a/vendor/ipl/html/src/DeferredText.php b/vendor/ipl/html/src/DeferredText.php
new file mode 100644
index 0000000..2d308f1
--- /dev/null
+++ b/vendor/ipl/html/src/DeferredText.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace ipl\Html;
+
+use Exception;
+
+/**
+ * Text node where content creation is deferred until rendering
+ *
+ * The content is created by a passed in callback which is only called when the node is going to be rendered and
+ * automatically escaped to HTML.
+ * If the created content is already escaped, see {@link setEscaped()} to indicate this.
+ *
+ * # Example Usage
+ * ```
+ * $benchmark = new Benchmark();
+ *
+ * $performance = new DeferredText(function () use ($benchmark) {
+ * return $benchmark->summary();
+ * });
+ *
+ * execute_query();
+ *
+ * $benchmark->tick('Fetched results');
+ *
+ * generate_report();
+ *
+ * $benchmark->tick('Report generated');
+ *
+ * echo $performance;
+ * ```
+ */
+class DeferredText implements ValidHtml
+{
+ /** @var callable will return the text that should be rendered */
+ protected $callback;
+
+ /** @var bool */
+ protected $escaped = false;
+
+ /**
+ * Create a new text node where content creation is deferred until rendering
+ *
+ * @param callable $callback Must return the content that should be rendered
+ */
+ public function __construct(callable $callback)
+ {
+ $this->callback = $callback;
+ }
+
+ /**
+ * Create a new text node where content creation is deferred until rendering
+ *
+ * @param callable $callback Must return the content that should be rendered
+ *
+ * @return static
+ */
+ public static function create(callable $callback)
+ {
+ return new static($callback);
+ }
+
+ /**
+ * Get whether the callback promises that its content is already escaped
+ *
+ * @return bool
+ */
+ public function isEscaped()
+ {
+ return $this->escaped;
+ }
+
+ /**
+ * Set whether the callback's content is already escaped
+ *
+ * @param bool $escaped
+ *
+ * @return $this
+ */
+ public function setEscaped($escaped = true)
+ {
+ $this->escaped = $escaped;
+
+ return $this;
+ }
+
+ /**
+ * Render text to HTML when treated like a string
+ *
+ * Calls {@link render()} internally in order to render the text to HTML.
+ * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}.
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return Error::render($e);
+ }
+ }
+
+ public function render()
+ {
+ $callback = $this->callback;
+
+ if ($this->escaped) {
+ return $callback();
+ } else {
+ return Html::escape($callback());
+ }
+ }
+}
diff --git a/vendor/ipl/html/src/Error.php b/vendor/ipl/html/src/Error.php
new file mode 100644
index 0000000..316d237
--- /dev/null
+++ b/vendor/ipl/html/src/Error.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace ipl\Html;
+
+use Exception;
+use Throwable;
+
+use function ipl\Stdlib\get_php_type;
+
+/**
+ * Class Error
+ *
+ * TODO: Eventually allow to (statically) inject a custom error renderer.
+ *
+ * @package ipl\Html
+ */
+abstract class Error
+{
+ /** @var bool */
+ protected static $showTraces = true;
+
+ /**
+ *
+ * @param Exception|Throwable|string $error
+ * @return HtmlDocument
+ */
+ public static function show($error)
+ {
+ if ($error instanceof Throwable) {
+ // PHP 7+
+ $msg = static::createMessageForException($error);
+ } elseif (is_string($error)) {
+ $msg = $error;
+ } else {
+ // TODO: translate?
+ $msg = 'Got an invalid error';
+ }
+
+ $result = static::renderErrorMessage($msg);
+ if (static::showTraces() && $error instanceof Throwable) {
+ $result->addHtml(Html::tag('pre', $error->getTraceAsString()));
+ }
+
+ return $result;
+ }
+
+ /**
+ *
+ * @param Exception|Throwable|string $error
+ * @return string
+ */
+ public static function render($error)
+ {
+ return static::show($error)->render();
+ }
+
+ /**
+ * @param bool|null $show
+ * @return bool
+ */
+ public static function showTraces($show = null)
+ {
+ if ($show !== null) {
+ self::$showTraces = (bool) $show;
+ }
+
+ return self::$showTraces;
+ }
+
+ /**
+ * @deprecated Use {@link get_php_type()} instead
+ */
+ public static function getPhpTypeName($any)
+ {
+ return get_php_type($any);
+ }
+
+ /**
+ * @param Exception|Throwable $exception
+ * @return string
+ */
+ protected static function createMessageForException($exception)
+ {
+ $file = preg_split('/[\/\\\]/', $exception->getFile(), -1, PREG_SPLIT_NO_EMPTY) ?: [];
+ $file = array_pop($file);
+ return sprintf(
+ '%s (%s:%d)',
+ $exception->getMessage(),
+ $file,
+ $exception->getLine()
+ );
+ }
+
+ /**
+ * @param string
+ * @return HtmlDocument
+ */
+ protected static function renderErrorMessage($message)
+ {
+ $output = new HtmlDocument();
+ $output->addHtml(
+ Html::tag('div', ['class' => 'exception'], [
+ Html::tag('h1', [
+ Html::tag('i', ['class' => 'icon-bug']),
+ // TODO: Translate? More hints?
+ 'Oops, an error occurred!'
+ ]),
+ Html::tag('pre', $message)
+ ])
+ );
+
+ return $output;
+ }
+}
diff --git a/vendor/ipl/html/src/Form.php b/vendor/ipl/html/src/Form.php
new file mode 100644
index 0000000..a7360c7
--- /dev/null
+++ b/vendor/ipl/html/src/Form.php
@@ -0,0 +1,402 @@
+<?php
+
+namespace ipl\Html;
+
+use Exception;
+use ipl\Html\Contract\FormElement;
+use ipl\Html\Contract\FormSubmitElement;
+use ipl\Html\FormElement\FormElements;
+use ipl\Stdlib\Messages;
+use Psr\Http\Message\ServerRequestInterface;
+
+class Form extends BaseHtmlElement
+{
+ use FormElements {
+ FormElements::remove as private removeElement;
+ }
+ use Messages;
+
+ public const ON_ELEMENT_REGISTERED = 'elementRegistered';
+ public const ON_ERROR = 'error';
+ public const ON_REQUEST = 'request';
+ public const ON_SUCCESS = 'success';
+ public const ON_SENT = 'sent';
+ public const ON_VALIDATE = 'validate';
+
+ /** @var string Form submission URL */
+ protected $action;
+
+ /** @var string HTTP method to submit the form with */
+ protected $method = 'POST';
+
+ /** @var FormSubmitElement Primary submit button */
+ protected $submitButton;
+
+ /** @var FormSubmitElement[] Other elements that may submit the form */
+ protected $submitElements = [];
+
+ /** @var bool Whether the form is valid */
+ protected $isValid;
+
+ /** @var ServerRequestInterface The server request being processed */
+ protected $request;
+
+ /** @var string */
+ protected $redirectUrl;
+
+ protected $tag = 'form';
+
+ /**
+ * Get whether the given value is empty
+ *
+ * @param mixed $value
+ *
+ * @return bool
+ */
+ public static function isEmptyValue($value): bool
+ {
+ return $value === null || $value === '' || $value === [];
+ }
+
+ /**
+ * Get the Form submission URL
+ *
+ * @return string|null
+ */
+ public function getAction()
+ {
+ return $this->action;
+ }
+
+ /**
+ * Set the Form submission URL
+ *
+ * @param string $action
+ *
+ * @return $this
+ */
+ public function setAction($action)
+ {
+ $this->action = $action;
+
+ return $this;
+ }
+
+ /**
+ * Get the HTTP method to submit the form with
+ *
+ * @return string
+ */
+ public function getMethod()
+ {
+ return $this->method;
+ }
+
+ /**
+ * Set the HTTP method to submit the form with
+ *
+ * @param string $method
+ *
+ * @return $this
+ */
+ public function setMethod($method)
+ {
+ $this->method = strtoupper($method);
+
+ return $this;
+ }
+
+ /**
+ * Get whether the form has a primary submit button
+ *
+ * @return bool
+ */
+ public function hasSubmitButton()
+ {
+ return $this->submitButton !== null;
+ }
+
+ /**
+ * Get the primary submit button
+ *
+ * @return FormSubmitElement|null
+ */
+ public function getSubmitButton()
+ {
+ return $this->submitButton;
+ }
+
+ /**
+ * Set the primary submit button
+ *
+ * @param FormSubmitElement $element
+ *
+ * @return $this
+ */
+ public function setSubmitButton(FormSubmitElement $element)
+ {
+ $this->submitButton = $element;
+
+ return $this;
+ }
+
+ /**
+ * Get the submit element used to send the form
+ *
+ * @return FormSubmitElement|null
+ */
+ public function getPressedSubmitElement()
+ {
+ foreach ($this->submitElements as $submitElement) {
+ if ($submitElement->hasBeenPressed()) {
+ return $submitElement;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return ServerRequestInterface|null
+ */
+ public function getRequest()
+ {
+ return $this->request;
+ }
+
+ public function setRequest($request)
+ {
+ $this->request = $request;
+ $this->emit(Form::ON_REQUEST, [$request]);
+
+ return $this;
+ }
+
+ /**
+ * Get the url to redirect to on success
+ *
+ * @return string
+ */
+ public function getRedirectUrl()
+ {
+ return $this->redirectUrl;
+ }
+
+ /**
+ * Set the url to redirect to on success
+ *
+ * @param string $url
+ *
+ * @return $this
+ */
+ public function setRedirectUrl($url)
+ {
+ $this->redirectUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * @param ServerRequestInterface $request
+ *
+ * @return $this
+ */
+ public function handleRequest(ServerRequestInterface $request)
+ {
+ $this->setRequest($request);
+
+ if (! $this->hasBeenSent()) {
+ // Always assemble
+ $this->ensureAssembled();
+
+ return $this;
+ }
+
+ switch ($request->getMethod()) {
+ case 'POST':
+ $params = $request->getParsedBody();
+
+ break;
+ case 'GET':
+ parse_str($request->getUri()->getQuery(), $params);
+
+ break;
+ default:
+ $params = [];
+ }
+
+ $params = array_merge_recursive($params, $request->getUploadedFiles());
+ $this->populate($params);
+
+ // Assemble after populate in order to conditionally provide form elements
+ $this->ensureAssembled();
+
+ if ($this->hasBeenSubmitted()) {
+ if ($this->isValid()) {
+ try {
+ $this->emit(Form::ON_SENT, [$this]);
+ $this->onSuccess();
+ $this->emitOnce(Form::ON_SUCCESS, [$this]);
+ } catch (Exception $e) {
+ $this->addMessage($e);
+ $this->onError();
+ $this->emit(Form::ON_ERROR, [$e, $this]);
+ }
+ } else {
+ $this->onError();
+ }
+ } else {
+ $this->validatePartial();
+ $this->emit(Form::ON_SENT, [$this]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get whether the form has been sent
+ *
+ * A form is considered sent if the request's method equals the form's method.
+ *
+ * @return bool
+ */
+ public function hasBeenSent()
+ {
+ if ($this->request === null) {
+ return false;
+ }
+
+ return $this->request->getMethod() === $this->getMethod();
+ }
+
+ /**
+ * Get whether the form has been submitted
+ *
+ * A form is submitted when it has been sent and when the primary submit button, if set, has been pressed.
+ * This method calls {@link hasBeenSent()} in order to detect whether the form has been sent.
+ *
+ * @return bool
+ */
+ public function hasBeenSubmitted()
+ {
+ if (! $this->hasBeenSent()) {
+ return false;
+ }
+
+ if ($this->hasSubmitButton()) {
+ return $this->getSubmitButton()->hasBeenPressed();
+ }
+
+ return true;
+ }
+
+ /**
+ * Get whether the form is valid
+ *
+ * {@link validate()} is called automatically if the form has not been validated before.
+ *
+ * @return bool
+ */
+ public function isValid()
+ {
+ if ($this->isValid === null) {
+ $this->validate();
+
+ $this->emit(self::ON_VALIDATE, [$this]);
+ }
+
+ return $this->isValid;
+ }
+
+ /**
+ * Validate all elements
+ *
+ * @return $this
+ */
+ public function validate()
+ {
+ $this->ensureAssembled();
+
+ $valid = true;
+ foreach ($this->getElements() as $element) {
+ $element->validate();
+ if (! $element->isValid()) {
+ $valid = false;
+ }
+ }
+
+ $this->isValid = $valid;
+
+ return $this;
+ }
+
+ /**
+ * Validate all elements that have a value
+ *
+ * @return $this
+ */
+ public function validatePartial()
+ {
+ $this->ensureAssembled();
+
+ foreach ($this->getElements() as $element) {
+ if ($element->hasValue()) {
+ $element->validate();
+ }
+ }
+
+ return $this;
+ }
+
+ public function remove(ValidHtml $elementOrHtml)
+ {
+ if ($this->submitButton === $elementOrHtml) {
+ $this->submitButton = null;
+ }
+
+ $this->removeElement($elementOrHtml);
+
+ return $this;
+ }
+
+ protected function onError()
+ {
+ $errors = Html::tag('ul', ['class' => 'errors']);
+ foreach ($this->getMessages() as $message) {
+ if ($message instanceof Exception) {
+ $message = $message->getMessage();
+ }
+
+ $errors->addHtml(Html::tag('li', $message));
+ }
+
+ if (! $errors->isEmpty()) {
+ $this->prependHtml($errors);
+ }
+ }
+
+ protected function onSuccess()
+ {
+ // $this->redirectOnSuccess();
+ }
+
+ protected function onElementRegistered(FormElement $element)
+ {
+ if ($element instanceof FormSubmitElement) {
+ $this->submitElements[$element->getName()] = $element;
+
+ if (! $this->hasSubmitButton()) {
+ $this->setSubmitButton($element);
+ }
+ }
+
+ $element->onRegistered($this);
+ }
+
+ protected function registerAttributeCallbacks(Attributes $attributes)
+ {
+ $attributes
+ ->registerAttributeCallback('action', [$this, 'getAction'], [$this, 'setAction'])
+ ->registerAttributeCallback('method', [$this, 'getMethod'], [$this, 'setMethod']);
+ }
+}
diff --git a/vendor/ipl/html/src/FormDecorator/CallbackDecorator.php b/vendor/ipl/html/src/FormDecorator/CallbackDecorator.php
new file mode 100644
index 0000000..1048bfb
--- /dev/null
+++ b/vendor/ipl/html/src/FormDecorator/CallbackDecorator.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace ipl\Html\FormDecorator;
+
+use Closure;
+use ipl\Html\Contract\FormElement;
+use ipl\Html\Contract\FormElementDecorator;
+use ipl\Html\HtmlDocument;
+
+class CallbackDecorator extends HtmlDocument implements FormElementDecorator
+{
+ /** @var Closure The decorating callback */
+ protected $callback;
+
+ /** @var FormElement The decorated form element */
+ protected $formElement;
+
+ /**
+ * Create a new CallbackDecorator
+ *
+ * @param Closure $callback
+ */
+ public function __construct(Closure $callback)
+ {
+ $this->callback = $callback;
+ }
+
+ public function decorate(FormElement $formElement)
+ {
+ $decorator = clone $this;
+
+ $decorator->formElement = $formElement;
+
+ $formElement->prependWrapper($decorator);
+ }
+
+ protected function assemble()
+ {
+ call_user_func($this->callback, $this->formElement, $this);
+ }
+}
diff --git a/vendor/ipl/html/src/FormDecorator/DdDtDecorator.php b/vendor/ipl/html/src/FormDecorator/DdDtDecorator.php
new file mode 100644
index 0000000..9cfec20
--- /dev/null
+++ b/vendor/ipl/html/src/FormDecorator/DdDtDecorator.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace ipl\Html\FormDecorator;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormElement\BaseFormElement;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+
+class DdDtDecorator extends BaseHtmlElement implements DecoratorInterface
+{
+ protected $tag = 'dl';
+
+ protected $dt;
+
+ protected $dd;
+
+ /** @var BaseFormElement */
+ protected $wrappedElement;
+
+ protected $ready = false;
+
+ /**
+ * @param BaseFormElement $element
+ * @return static
+ */
+ public function decorate(BaseFormElement $element)
+ {
+ // TODO: ignore hidden?
+ $newWrapper = clone($this);
+ $newWrapper->wrappedElement = $element;
+ $element->prependWrapper($newWrapper);
+
+ return $newWrapper;
+ }
+
+ protected function renderLabel()
+ {
+ if ($this->wrappedElement instanceof BaseFormElement) {
+ $label = $this->wrappedElement->getLabel();
+ if ($label) {
+ return Html::tag('label', null, $label);
+ }
+ }
+
+ return null;
+ }
+
+ public function getAttributes()
+ {
+ $attributes = parent::getAttributes();
+
+ if ($this->wrappedElement->hasBeenValidated() && ! $this->wrappedElement->isValid()) {
+ $classes = $attributes->get('class')->getValue();
+ if (
+ empty($classes)
+ || (is_array($classes) && ! in_array('errors', $classes))
+ || (is_string($classes) && $classes !== 'errors')
+ ) {
+ $attributes->add('class', 'errors');
+ }
+ }
+
+ return $attributes;
+ }
+
+ protected function renderDescription()
+ {
+ if ($this->wrappedElement instanceof BaseFormElement) {
+ $description = $this->wrappedElement->getDescription();
+ if ($description) {
+ return Html::tag('p', ['class' => 'description'], $description);
+ }
+ }
+
+ return null;
+ }
+
+ protected function renderErrors()
+ {
+ if ($this->wrappedElement instanceof BaseFormElement) {
+ $errors = [];
+ foreach ($this->wrappedElement->getMessages() as $message) {
+ $errors[] = Html::tag('p', ['class' => 'error'], $message);
+ }
+
+ if (! empty($errors)) {
+ return $errors;
+ }
+ }
+
+ return null;
+ }
+
+ public function addHtml(ValidHtml ...$content)
+ {
+ // TODO: is this required?
+ if (! in_array($this->wrappedElement, $content, true)) {
+ parent::addHtml(...$content);
+ }
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $this->addHtml($this->dt(), $this->dd());
+ $this->ready = true;
+ }
+
+ protected function dt()
+ {
+ if ($this->dt === null) {
+ $this->dt = Html::tag('dt', null, $this->renderLabel());
+ }
+
+ return $this->dt;
+ }
+
+ /**
+ * @return \ipl\Html\HtmlElement
+ */
+ protected function dd()
+ {
+ if ($this->dd === null) {
+ $this->dd = Html::tag('dd', null, [
+ $this->wrappedElement,
+ $this->renderErrors(),
+ $this->renderDescription()
+ ]);
+ }
+
+ return $this->dd;
+ }
+
+ public function __destruct()
+ {
+ $this->wrapper = null;
+ }
+}
diff --git a/vendor/ipl/html/src/FormDecorator/DecoratorInterface.php b/vendor/ipl/html/src/FormDecorator/DecoratorInterface.php
new file mode 100644
index 0000000..1ed1a38
--- /dev/null
+++ b/vendor/ipl/html/src/FormDecorator/DecoratorInterface.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace ipl\Html\FormDecorator;
+
+use ipl\Html\Contract\FormElementDecorator;
+use ipl\Html\FormElement\BaseFormElement;
+
+/** @deprecated Use {@link FormElementDecorator} instead */
+interface DecoratorInterface
+{
+ /**
+ * Set the form element to decorate
+ *
+ * @param BaseFormElement $formElement
+ *
+ * @return static
+ */
+ public function decorate(BaseFormElement $formElement);
+}
diff --git a/vendor/ipl/html/src/FormDecorator/DivDecorator.php b/vendor/ipl/html/src/FormDecorator/DivDecorator.php
new file mode 100644
index 0000000..4935f01
--- /dev/null
+++ b/vendor/ipl/html/src/FormDecorator/DivDecorator.php
@@ -0,0 +1,156 @@
+<?php
+
+namespace ipl\Html\FormDecorator;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Contract\FormElement;
+use ipl\Html\Contract\FormElementDecorator;
+use ipl\Html\Contract\FormSubmitElement;
+use ipl\Html\FormElement\FieldsetElement;
+use ipl\Html\FormElement\HiddenElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+
+/**
+ * Form element decorator based on div elements
+ */
+class DivDecorator extends BaseHtmlElement implements FormElementDecorator
+{
+ /** @var string CSS class to use for submit elements */
+ public const SUBMIT_ELEMENT_CLASS = 'form-control';
+
+ /** @var string CSS class to use for all input elements */
+ public const INPUT_ELEMENT_CLASS = 'form-element';
+
+ /** @var string CSS class to use for form descriptions */
+ public const DESCRIPTION_CLASS = 'form-element-description';
+
+ /** @var string CSS class to use for form errors */
+ public const ERROR_CLASS = 'form-element-errors';
+
+ /** @var string CSS class to set on the decorator if the element has errors */
+ public const ERROR_HINT_CLASS = 'has-error';
+
+ /** @var FormElement The decorated form element */
+ protected $formElement;
+
+ protected $tag = 'div';
+
+ public function decorate(FormElement $formElement)
+ {
+ if ($formElement instanceof HiddenElement) {
+ return;
+ }
+
+ $decorator = clone $this;
+
+ /**
+ * Wrapper logic can be overridden to propagate the decorator.
+ * So here we make sure that a yet unbound decorator is passed.
+ *
+ * {@see FieldsetElement::setWrapper()}
+ */
+ $formElement->prependWrapper($decorator);
+
+ $decorator->formElement = $formElement;
+
+ $classes = [static::INPUT_ELEMENT_CLASS];
+ if ($formElement instanceof FormSubmitElement) {
+ $classes[] = static::SUBMIT_ELEMENT_CLASS;
+ }
+
+ $decorator->getAttributes()->add('class', $classes);
+ }
+
+ protected function assembleDescription()
+ {
+ $description = $this->formElement->getDescription();
+
+ if ($description !== null) {
+ $descriptionId = null;
+ if ($this->formElement->getAttributes()->has('id')) {
+ $descriptionId = 'desc_' . $this->formElement->getAttributes()->get('id')->getValue();
+ $this->formElement->getAttributes()->set('aria-describedby', $descriptionId);
+ }
+
+ return Html::tag('p', ['id' => $descriptionId, 'class' => static::DESCRIPTION_CLASS], $description);
+ }
+
+ return null;
+ }
+
+ protected function assembleElement()
+ {
+ if ($this->formElement->isRequired()) {
+ $this->formElement->getAttributes()->set('aria-required', 'true');
+ }
+
+ return $this->formElement->ensureAssembled();
+ }
+
+ protected function assembleErrors()
+ {
+ $errors = new HtmlElement('ul', Attributes::create(['class' => static::ERROR_CLASS]));
+
+ foreach ($this->formElement->getMessages() as $message) {
+ $errors->addHtml(
+ new HtmlElement('li', Attributes::create(['class' => static::ERROR_CLASS]), Text::create($message))
+ );
+ }
+
+ if (! $errors->isEmpty()) {
+ return $errors;
+ }
+
+ return null;
+ }
+
+ protected function assembleLabel()
+ {
+ $label = $this->formElement->getLabel();
+
+ if ($label !== null) {
+ if ($this->formElement instanceof FieldsetElement) {
+ return new HtmlElement('legend', null, Text::create($label));
+ } else {
+ $attributes = null;
+ if ($this->formElement->getAttributes()->has('id')) {
+ $attributes = new Attributes(['for' => $this->formElement->getAttributes()->get('id')->getValue()]);
+ }
+
+ return Html::tag('label', $attributes, $label);
+ }
+ }
+
+ return null;
+ }
+
+ protected function assemble()
+ {
+ if ($this->formElement->hasBeenValidated() && ! $this->formElement->isValid()) {
+ $this->getAttributes()->add('class', static::ERROR_HINT_CLASS);
+ }
+
+ if ($this->formElement instanceof FieldsetElement) {
+ $element = $this->assembleElement();
+ $element->prependHtml(...Html::wantHtmlList([
+ $this->assembleLabel(),
+ $this->assembleDescription()
+ ]));
+
+ $this->addHtml(...Html::wantHtmlList([
+ $element,
+ $this->assembleErrors()
+ ]));
+ } else {
+ $this->addHtml(...Html::wantHtmlList([
+ $this->assembleLabel(),
+ $this->assembleElement(),
+ $this->assembleDescription(),
+ $this->assembleErrors()
+ ]));
+ }
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/BaseFormElement.php b/vendor/ipl/html/src/FormElement/BaseFormElement.php
new file mode 100644
index 0000000..837ac45
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/BaseFormElement.php
@@ -0,0 +1,390 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use ipl\Html\Attribute;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Contract\FormElement;
+use ipl\Html\Contract\ValueCandidates;
+use ipl\Html\Form;
+use ipl\I18n\Translation;
+use ipl\Stdlib\Messages;
+use ipl\Validator\ValidatorChain;
+use ReflectionProperty;
+
+abstract class BaseFormElement extends BaseHtmlElement implements FormElement, ValueCandidates
+{
+ use Messages;
+ use Translation;
+
+ /** @var string Description of the element */
+ protected $description;
+
+ /** @var string Label of the element */
+ protected $label;
+
+ /** @var string Name of the element */
+ protected $name;
+
+ /** @var bool Whether the element is ignored */
+ protected $ignored = false;
+
+ /** @var bool Whether the element is required */
+ protected $required = false;
+
+ /** @var null|bool Whether the element is valid; null if the element has not been validated yet, bool otherwise */
+ protected $valid;
+
+ /** @var ValidatorChain Registered validators */
+ protected $validators;
+
+ /** @var mixed Value of the element */
+ protected $value;
+
+ /** @var array<int, mixed> Value candidates of the element */
+ protected $valueCandidates = [];
+
+ /**
+ * Create a new form element
+ *
+ * @param string $name Name of the form element
+ * @param mixed $attributes Attributes of the form element
+ */
+ public function __construct($name, $attributes = null)
+ {
+ $this->setName($name);
+ $this->init();
+
+ if ($attributes !== null) {
+ $this->addAttributes($attributes);
+ }
+ }
+
+ public function getDescription()
+ {
+ return $this->description;
+ }
+
+ /**
+ * Set the description of the element
+ *
+ * @param string $description
+ *
+ * @return $this
+ */
+ public function setDescription($description)
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ public function getLabel()
+ {
+ return $this->label;
+ }
+
+ /**
+ * Set the label of the element
+ *
+ * @param string $label
+ *
+ * @return $this
+ */
+ public function setLabel($label)
+ {
+ $this->label = $label;
+
+ return $this;
+ }
+
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set the name for the element
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function isIgnored()
+ {
+ return $this->ignored;
+ }
+
+ /**
+ * Set whether the element is ignored
+ *
+ * @param bool $ignored
+ *
+ * @return $this
+ */
+ public function setIgnored($ignored = true)
+ {
+ $this->ignored = (bool) $ignored;
+
+ return $this;
+ }
+
+ public function isRequired()
+ {
+ return $this->required;
+ }
+
+ /**
+ * Set whether the element is required
+ *
+ * @param bool $required
+ *
+ * @return $this
+ */
+ public function setRequired($required = true)
+ {
+ $this->required = (bool) $required;
+
+ return $this;
+ }
+
+ public function isValid()
+ {
+ if ($this->valid === null) {
+ $this->validate();
+ }
+
+ return $this->valid;
+ }
+
+ /**
+ * Get the validators
+ *
+ * @return ValidatorChain
+ */
+ public function getValidators()
+ {
+ if ($this->validators === null) {
+ $chain = new ValidatorChain();
+ $this->addDefaultValidators($chain);
+ $this->validators = $chain;
+ }
+
+ return $this->validators;
+ }
+
+ /**
+ * Set the validators
+ *
+ * @param iterable $validators
+ *
+ * @return $this
+ */
+ public function setValidators($validators)
+ {
+ $this
+ ->getValidators()
+ ->clearValidators()
+ ->addValidators($validators);
+
+ return $this;
+ }
+
+ /**
+ * Add validators
+ *
+ * @param iterable $validators
+ *
+ * @return $this
+ */
+ public function addValidators($validators)
+ {
+ $this->getValidators()->addValidators($validators);
+
+ return $this;
+ }
+
+ public function hasValue()
+ {
+ $value = $this->getValue();
+
+ return ! Form::isEmptyValue($value);
+ }
+
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ public function setValue($value)
+ {
+ if ($value === '') {
+ $this->value = null;
+ } else {
+ $this->value = $value;
+ }
+
+ $this->valid = null;
+
+ return $this;
+ }
+
+ public function getValueCandidates()
+ {
+ return $this->valueCandidates;
+ }
+
+ public function setValueCandidates(array $values)
+ {
+ $this->valueCandidates = $values;
+
+ return $this;
+ }
+
+ public function onRegistered(Form $form)
+ {
+ }
+
+ /**
+ * Validate the element using all registered validators
+ *
+ * @return $this
+ */
+ public function validate()
+ {
+ $this->ensureAssembled();
+
+ if (! $this->hasValue()) {
+ if ($this->isRequired()) {
+ $this->setMessages([$this->translate('This field is required.')]);
+ $this->valid = false;
+ } else {
+ $this->valid = true;
+ }
+ } else {
+ $this->valid = $this->getValidators()->isValid($this->getValue());
+ $this->setMessages($this->getValidators()->getMessages());
+ }
+
+ return $this;
+ }
+
+ public function hasBeenValidated()
+ {
+ return $this->valid !== null;
+ }
+
+ /**
+ * Callback for the name attribute
+ *
+ * @return Attribute|string
+ */
+ public function getNameAttribute()
+ {
+ return $this->getName();
+ }
+
+ /**
+ * Callback for the required attribute
+ *
+ * @return Attribute|null
+ */
+ public function getRequiredAttribute()
+ {
+ if ($this->isRequired()) {
+ return new Attribute('required', true);
+ }
+
+ return null;
+ }
+
+ /**
+ * Callback for the value attribute
+ *
+ * @return mixed
+ */
+ public function getValueAttribute()
+ {
+ return $this->getValue();
+ }
+
+ /**
+ * Initialize this form element
+ *
+ * If you want to initialize this element after construction, override this method
+ */
+ protected function init(): void
+ {
+ }
+
+ /**
+ * Add default validators
+ */
+ protected function addDefaultValidators(ValidatorChain $chain): void
+ {
+ }
+
+ protected function registerValueCallback(Attributes $attributes)
+ {
+ $attributes->registerAttributeCallback(
+ 'value',
+ [$this, 'getValueAttribute'],
+ [$this, 'setValue']
+ );
+ }
+
+ protected function registerAttributeCallbacks(Attributes $attributes)
+ {
+ $this->registerValueCallback($attributes);
+
+ $attributes
+ ->registerAttributeCallback('label', null, [$this, 'setLabel'])
+ ->registerAttributeCallback('name', [$this, 'getNameAttribute'], [$this, 'setName'])
+ ->registerAttributeCallback('description', null, [$this, 'setDescription'])
+ ->registerAttributeCallback('validators', null, [$this, 'setValidators'])
+ ->registerAttributeCallback('ignore', null, [$this, 'setIgnored'])
+ ->registerAttributeCallback('required', [$this, 'getRequiredAttribute'], [$this, 'setRequired']);
+
+ $this->registerCallbacks();
+ }
+
+ /** @deprecated Use {@link registerAttributeCallbacks()} instead */
+ protected function registerCallbacks()
+ {
+ }
+
+ /**
+ * @deprecated
+ *
+ * {@see Attributes::get()} does not respect callbacks,
+ * but we need the value of the callback to nest attribute names.
+ */
+ protected function getValueOfNameAttribute()
+ {
+ $attributes = $this->getAttributes();
+
+ $callbacksProperty = new ReflectionProperty(get_class($attributes), 'callbacks');
+ $callbacksProperty->setAccessible(true);
+ $callbacks = $callbacksProperty->getValue($attributes);
+
+ if (isset($callbacks['name'])) {
+ $name = $callbacks['name']();
+
+ if ($name instanceof Attribute) {
+ return $name->getValue();
+ }
+
+ return $name;
+ }
+
+ return $this->getName();
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/ButtonElement.php b/vendor/ipl/html/src/FormElement/ButtonElement.php
new file mode 100644
index 0000000..63ae540
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/ButtonElement.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+class ButtonElement extends BaseFormElement
+{
+ protected $tag = 'button';
+}
diff --git a/vendor/ipl/html/src/FormElement/CheckboxElement.php b/vendor/ipl/html/src/FormElement/CheckboxElement.php
new file mode 100644
index 0000000..37e036a
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/CheckboxElement.php
@@ -0,0 +1,124 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use ipl\Html\Attributes;
+
+class CheckboxElement extends InputElement
+{
+ /** @var bool Whether the checkbox is checked */
+ protected $checked = false;
+
+ /** @var string Value of the checkbox when it is checked */
+ protected $checkedValue = 'y';
+
+ /** @var string Value of the checkbox when it is not checked */
+ protected $uncheckedValue = 'n';
+
+ protected $type = 'checkbox';
+
+ /**
+ * Get whether the checkbox is checked
+ *
+ * @return bool
+ */
+ public function isChecked()
+ {
+ return $this->checked;
+ }
+
+ /**
+ * Set whether the checkbox is checked
+ *
+ * @param bool $checked
+ *
+ * @return $this
+ */
+ public function setChecked($checked)
+ {
+ $this->checked = (bool) $checked;
+
+ return $this;
+ }
+
+ /**
+ * Get the value of the checkbox when it is checked
+ *
+ * @return string
+ */
+ public function getCheckedValue()
+ {
+ return $this->checkedValue;
+ }
+
+ /**
+ * Set the value of the checkbox when it is checked
+ *
+ * @param string $checkedValue
+ *
+ * @return $this
+ */
+ public function setCheckedValue($checkedValue)
+ {
+ $this->checkedValue = $checkedValue;
+
+ return $this;
+ }
+
+ /**
+ * Get the value of the checkbox when it is not checked
+ *
+ * @return string
+ */
+ public function getUncheckedValue()
+ {
+ return $this->uncheckedValue;
+ }
+
+ /**
+ * Set the value of the checkbox when it is not checked
+ *
+ * @param string $uncheckedValue
+ *
+ * @return $this
+ */
+ public function setUncheckedValue($uncheckedValue)
+ {
+ $this->uncheckedValue = $uncheckedValue;
+
+ return $this;
+ }
+
+ public function setValue($value)
+ {
+ if (is_bool($value)) {
+ $value = $value ? $this->getCheckedValue() : $this->getUncheckedValue();
+ }
+
+ $this->setChecked($value === $this->getCheckedValue());
+
+ return parent::setValue($value);
+ }
+
+ public function getValueAttribute()
+ {
+ return $this->getCheckedValue();
+ }
+
+ protected function registerAttributeCallbacks(Attributes $attributes)
+ {
+ parent::registerAttributeCallbacks($attributes);
+
+ $attributes
+ ->registerAttributeCallback('checked', [$this, 'isChecked'], [$this, 'setChecked'])
+ ->registerAttributeCallback('checkedValue', null, [$this, 'setCheckedValue'])
+ ->registerAttributeCallback('uncheckedValue', null, [$this, 'setUncheckedValue']);
+ }
+
+ public function renderUnwrapped()
+ {
+ $html = parent::renderUnwrapped();
+
+ return (new HiddenElement($this->getValueOfNameAttribute(), ['value' => $this->getUncheckedValue()])) . $html;
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/ColorElement.php b/vendor/ipl/html/src/FormElement/ColorElement.php
new file mode 100644
index 0000000..21d6c3a
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/ColorElement.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use ipl\Validator\HexColorValidator;
+use ipl\Validator\ValidatorChain;
+
+class ColorElement extends InputElement
+{
+ protected $type = 'color';
+
+ protected function addDefaultValidators(ValidatorChain $chain): void
+ {
+ $chain->add(new HexColorValidator());
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/DateElement.php b/vendor/ipl/html/src/FormElement/DateElement.php
new file mode 100644
index 0000000..2f73b3c
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/DateElement.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+class DateElement extends InputElement
+{
+ protected $type = 'date';
+}
diff --git a/vendor/ipl/html/src/FormElement/FieldsetElement.php b/vendor/ipl/html/src/FormElement/FieldsetElement.php
new file mode 100644
index 0000000..0d70ea4
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/FieldsetElement.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use InvalidArgumentException;
+use ipl\Html\Contract\FormElement;
+use ipl\Html\Contract\FormElementDecorator;
+use ipl\Html\Contract\Wrappable;
+use LogicException;
+
+use function ipl\Stdlib\get_php_type;
+
+class FieldsetElement extends BaseFormElement
+{
+ use FormElements {
+ FormElements::getValue as private getElementValue;
+ }
+
+ protected $tag = 'fieldset';
+
+ /**
+ * Get whether any of this set's elements has a value
+ *
+ * @return bool
+ */
+ public function hasValue()
+ {
+ foreach ($this->getElements() as $element) {
+ if ($element->hasValue()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function getValue($name = null, $default = null)
+ {
+ if ($name === null) {
+ if ($default !== null) {
+ throw new LogicException("Can't provide default without a name");
+ }
+
+ return $this->getValues();
+ }
+
+ return $this->getElementValue($name, $default);
+ }
+
+ public function setValue($value)
+ {
+ if (! is_iterable($value)) {
+ throw new InvalidArgumentException(
+ sprintf(
+ '%s expects parameter $value to be an array|iterable, got %s instead',
+ __METHOD__,
+ get_php_type($value)
+ )
+ );
+ }
+
+ // We expect an array/iterable here,
+ // so call populate to loop through it and apply values to the child elements of the fieldset.
+ $this->populate($value);
+
+ return $this;
+ }
+
+ public function validate()
+ {
+ $this->ensureAssembled();
+
+ $this->valid = true;
+ foreach ($this->getElements() as $element) {
+ $element->validate();
+ if (! $element->isValid()) {
+ $this->valid = false;
+ }
+ }
+
+ if ($this->valid) {
+ parent::validate();
+ }
+
+ return $this;
+ }
+
+ public function getValueAttribute()
+ {
+ // Fieldsets do not have the value attribute.
+ return null;
+ }
+
+ public function setWrapper(Wrappable $wrapper)
+ {
+ // TODO(lippserd): Revise decorator implementation to properly implement decorator propagation
+ if (
+ ! $this->hasDefaultElementDecorator()
+ && $wrapper instanceof FormElementDecorator
+ ) {
+ $this->setDefaultElementDecorator(clone $wrapper);
+ }
+
+ return parent::setWrapper($wrapper);
+ }
+
+ protected function onElementRegistered(FormElement $element)
+ {
+ $element->getAttributes()->registerAttributeCallback('name', function () use ($element) {
+ /**
+ * We don't change the {@see BaseFormElement::$name} property of the element,
+ * otherwise methods like {@see FormElements::populate() and {@see FormElements::getElement()} would fail,
+ * but only change the name attribute to nest the names.
+ */
+ return sprintf(
+ '%s[%s]',
+ $this->getValueOfNameAttribute(),
+ $element->getName()
+ );
+ });
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/FileElement.php b/vendor/ipl/html/src/FormElement/FileElement.php
new file mode 100644
index 0000000..d9ca8fd
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/FileElement.php
@@ -0,0 +1,414 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use GuzzleHttp\Psr7\UploadedFile;
+use InvalidArgumentException;
+use ipl\Html\Attributes;
+use ipl\Html\Form;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use ipl\Validator\FileValidator;
+use ipl\Validator\ValidatorChain;
+use Psr\Http\Message\UploadedFileInterface;
+use ipl\Html\Common\MultipleAttribute;
+
+use function ipl\Stdlib\get_php_type;
+
+/**
+ * File upload element
+ *
+ * Once the file element is added to the form and the form attribute `enctype` is not set,
+ * it is automatically set to `multipart/form-data`.
+ */
+class FileElement extends InputElement
+{
+ use MultipleAttribute;
+ use Translation;
+
+ protected $type = 'file';
+
+ /** @var UploadedFileInterface|UploadedFileInterface[] */
+ protected $value;
+
+ /** @var UploadedFileInterface[] Files that are stored on disk */
+ protected $files = [];
+
+ /** @var string[] Files to be removed from disk */
+ protected $filesToRemove = [];
+
+ /** @var ?string Path to store files to preserve them across requests */
+ protected $destination;
+
+ /** @var int The default maximum file size */
+ protected static $defaultMaxFileSize;
+
+ public function __construct($name, $attributes = null)
+ {
+ $this->getAttributes()->get('accept')->setSeparator(', ');
+
+ parent::__construct($name, $attributes);
+ }
+
+ /**
+ * Get the path to store files to preserve them across requests
+ *
+ * @return string
+ */
+ public function getDestination(): ?string
+ {
+ return $this->destination;
+ }
+
+ /**
+ * Set the path to store files to preserve them across requests
+ *
+ * Uploaded files are moved to the given directory to
+ * retain the file through automatic form submissions and failed form validations.
+ *
+ * Please note that using file persistence currently has the following drawbacks:
+ *
+ * * Works only if the file element is added to the form during {@link Form::assemble()}.
+ * * Persisted files are not removed automatically.
+ * * Files with the same name override each other.
+ *
+ * @param string $path
+ *
+ * @return $this
+ */
+ public function setDestination(string $path): self
+ {
+ $this->destination = $path;
+
+ return $this;
+ }
+
+ public function getValueAttribute()
+ {
+ // Value attributes of file inputs are set only client-side.
+ return null;
+ }
+
+ public function getNameAttribute()
+ {
+ $name = $this->getName();
+
+ return $this->isMultiple() ? ($name . '[]') : $name;
+ }
+
+ public function hasValue()
+ {
+ if ($this->value === null) {
+ $files = $this->loadFiles();
+ if (empty($files)) {
+ return false;
+ }
+
+ if (! $this->isMultiple()) {
+ $files = $files[0];
+ }
+
+ $this->value = $files;
+ }
+
+ return $this->value !== null;
+ }
+
+ public function setValue($value)
+ {
+ if (! empty($value)) {
+ $fileToTest = $value;
+ if ($this->isMultiple()) {
+ $fileToTest = $value[0];
+ }
+
+ if (! $fileToTest instanceof UploadedFileInterface) {
+ throw new InvalidArgumentException(
+ sprintf('%s is not an uploaded file', get_php_type($fileToTest))
+ );
+ }
+
+ if ($fileToTest->getError() === UPLOAD_ERR_NO_FILE && ! $fileToTest->getClientFilename()) {
+ // This is checked here as it's only about file elements for which no value has been chosen
+ $value = null;
+ } else {
+ $files = $value;
+ if (! $this->isMultiple()) {
+ $files = [$files];
+ }
+
+ /** @var UploadedFileInterface[] $files */
+ $storedFiles = $this->storeFiles(...$files);
+ if (! $this->isMultiple()) {
+ $storedFiles = $storedFiles[0] ?? null;
+ }
+
+ $value = $storedFiles;
+ }
+ } else {
+ $value = null;
+ }
+
+ return parent::setValue($value);
+ }
+
+ /**
+ * Get whether there are any files stored on disk
+ *
+ * @return bool
+ */
+ protected function hasFiles(): bool
+ {
+ return $this->destination !== null && reset($this->files);
+ }
+
+ /**
+ * Load and return all files stored on disk
+ *
+ * @return UploadedFileInterface[]
+ */
+ protected function loadFiles(): array
+ {
+ if (empty($this->files) || $this->destination === null) {
+ return [];
+ }
+
+ foreach ($this->files as $name => $_) {
+ $filePath = $this->getFilePath($name);
+ if (! is_readable($filePath) || ! is_file($filePath)) {
+ // If one file isn't accessible, none is
+ return [];
+ }
+
+ if (in_array($name, $this->filesToRemove, true)) {
+ @unlink($filePath);
+ } else {
+ $this->files[$name] = new UploadedFile(
+ $filePath,
+ filesize($filePath) ?: null,
+ 0,
+ $name,
+ mime_content_type($filePath) ?: null
+ );
+ }
+ }
+
+ $this->files = array_diff_key($this->files, array_flip($this->filesToRemove));
+
+ return array_values($this->files);
+ }
+
+ /**
+ * Store the given files on disk
+ *
+ * @param UploadedFileInterface ...$files
+ *
+ * @return UploadedFileInterface[]
+ */
+ protected function storeFiles(UploadedFileInterface ...$files): array
+ {
+ if ($this->destination === null || ! is_writable($this->destination)) {
+ return $files;
+ }
+
+ $storedFiles = [];
+ foreach ($files as $file) {
+ $name = $file->getClientFilename();
+ $path = $this->getFilePath($name);
+
+ if ($file->getError() !== UPLOAD_ERR_OK) {
+ // The file is still returned as otherwise it won't be validated
+ $storedFiles[] = $file;
+ continue;
+ }
+
+ $file->moveTo($path);
+
+ // Re-created to ensure moveTo() still works if called externally
+ $file = new UploadedFile(
+ $path,
+ $file->getSize(),
+ 0,
+ $name,
+ $file->getClientMediaType()
+ );
+
+ $this->files[$name] = $file;
+ $storedFiles[] = $file;
+ }
+
+ return $storedFiles;
+ }
+
+ /**
+ * Get the file path on disk of the given file
+ *
+ * @param string $name
+ *
+ * @return string
+ */
+ protected function getFilePath(string $name): string
+ {
+ return implode(DIRECTORY_SEPARATOR, [$this->destination, sha1($name)]);
+ }
+
+ public function onRegistered(Form $form)
+ {
+ if (! $form->hasAttribute('enctype')) {
+ $form->setAttribute('enctype', 'multipart/form-data');
+ }
+
+ $chosenFiles = (array) $form->getPopulatedValue('chosen_file_' . $this->getName(), []);
+ foreach ($chosenFiles as $chosenFile) {
+ $this->files[$chosenFile] = null;
+ }
+
+ $this->filesToRemove = (array) $form->getPopulatedValue('remove_file_' . $this->getName(), []);
+ }
+
+ protected function addDefaultValidators(ValidatorChain $chain): void
+ {
+ $chain->add(new FileValidator([
+ 'maxSize' => $this->getDefaultMaxFileSize(),
+ 'mimeType' => array_filter(
+ (array) $this->getAttributes()->get('accept')->getValue(),
+ function ($type) {
+ // file inputs also allow file extensions in the accept attribute. These
+ // must not be passed as they don't resemble valid mime type definitions.
+ return is_string($type) && ltrim($type)[0] !== '.';
+ }
+ )
+ ]));
+ }
+
+ protected function registerAttributeCallbacks(Attributes $attributes)
+ {
+ parent::registerAttributeCallbacks($attributes);
+ $this->registerMultipleAttributeCallback($attributes);
+ $this->getAttributes()->registerAttributeCallback('destination', null, [$this, 'setDestination']);
+ }
+
+ /**
+ * Get the system's default maximum file upload size
+ *
+ * @return int
+ */
+ public function getDefaultMaxFileSize(): int
+ {
+ if (static::$defaultMaxFileSize === null) {
+ $ini = $this->convertIniToInteger(trim(static::getPostMaxSize()));
+ $max = $this->convertIniToInteger(trim(static::getUploadMaxFilesize()));
+ $min = max($ini, $max);
+ if ($ini > 0) {
+ $min = min($min, $ini);
+ }
+
+ if ($max > 0) {
+ $min = min($min, $max);
+ }
+
+ static::$defaultMaxFileSize = $min;
+ }
+
+ return static::$defaultMaxFileSize;
+ }
+
+ /**
+ * Converts a ini setting to a integer value
+ *
+ * @param string $setting
+ *
+ * @return int
+ */
+ private function convertIniToInteger(string $setting): int
+ {
+ if (! is_numeric($setting)) {
+ $type = strtoupper(substr($setting, -1));
+ $setting = (int) substr($setting, 0, -1);
+
+ switch ($type) {
+ case 'K':
+ $setting *= 1024;
+ break;
+
+ case 'M':
+ $setting *= 1024 * 1024;
+ break;
+
+ case 'G':
+ $setting *= 1024 * 1024 * 1024;
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ return (int) $setting;
+ }
+
+ /**
+ * Get the `post_max_size` INI setting
+ *
+ * @return string
+ */
+ protected static function getPostMaxSize(): string
+ {
+ return ini_get('post_max_size') ?: '8M';
+ }
+
+ /**
+ * Get the `upload_max_filesize` INI setting
+ *
+ * @return string
+ */
+ protected static function getUploadMaxFilesize(): string
+ {
+ return ini_get('upload_max_filesize') ?: '2M';
+ }
+
+ protected function assemble()
+ {
+ $doc = new HtmlDocument();
+ if ($this->hasFiles()) {
+ foreach ($this->files as $file) {
+ $doc->addHtml(new HiddenElement('chosen_file_' . $this->getValueOfNameAttribute(), [
+ 'value' => $file->getClientFilename()
+ ]));
+ }
+
+ $this->prependWrapper($doc);
+ }
+ }
+
+ public function renderUnwrapped()
+ {
+ if (! $this->hasValue() || ! $this->hasFiles()) {
+ return parent::renderUnwrapped();
+ }
+
+ $uploadedFiles = new HtmlElement('ul', Attributes::create(['class' => 'uploaded-files']));
+ foreach ($this->files as $file) {
+ $uploadedFiles->addHtml(new HtmlElement(
+ 'li',
+ null,
+ (new ButtonElement('remove_file_' . $this->getValueOfNameAttribute(), Attributes::create([
+ 'type' => 'submit',
+ 'formnovalidate' => true,
+ 'class' => 'remove-uploaded-file',
+ 'value' => $file->getClientFilename(),
+ 'title' => sprintf($this->translate('Remove file "%s"'), $file->getClientFilename())
+ ])))->addHtml(new HtmlElement(
+ 'span',
+ null,
+ new HtmlElement('i', Attributes::create(['class' => ['icon', 'fa', 'fa-xmark']])),
+ Text::create($file->getClientFilename())
+ ))
+ ));
+ }
+
+ return $uploadedFiles->render();
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/FormElements.php b/vendor/ipl/html/src/FormElement/FormElements.php
new file mode 100644
index 0000000..4a2c598
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/FormElements.php
@@ -0,0 +1,509 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use InvalidArgumentException;
+use ipl\Html\Contract\FormElement;
+use ipl\Html\Contract\FormElementDecorator;
+use ipl\Html\Contract\ValueCandidates;
+use ipl\Html\Form;
+use ipl\Html\FormDecorator\DecoratorInterface;
+use ipl\Html\ValidHtml;
+use ipl\Stdlib\Events;
+use ipl\Stdlib\Plugins;
+use UnexpectedValueException;
+
+use function ipl\Stdlib\get_php_type;
+
+trait FormElements
+{
+ use Events;
+ use Plugins;
+
+ /** @var FormElementDecorator|null */
+ private $defaultElementDecorator;
+
+ /** @var bool Whether the default element decorator loader has been registered */
+ protected $defaultElementDecoratorLoaderRegistered = false;
+
+ /** @var bool Whether the default element loader has been registered */
+ protected $defaultElementLoaderRegistered = false;
+
+ /** @var FormElement[] */
+ private $elements = [];
+
+ /** @var array<string, array<int, mixed>> */
+ private $populatedValues = [];
+
+ /**
+ * Get all elements
+ *
+ * @return FormElement[]
+ */
+ public function getElements()
+ {
+ return $this->elements;
+ }
+
+ /**
+ * Get whether the given element exists
+ *
+ * @param string|FormElement $element
+ *
+ * @return bool
+ */
+ public function hasElement($element)
+ {
+ if (is_string($element)) {
+ return array_key_exists($element, $this->elements);
+ }
+
+ if ($element instanceof FormElement) {
+ return in_array($element, $this->elements, true);
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the element by the given name
+ *
+ * @param string $name
+ *
+ * @return FormElement
+ *
+ * @throws InvalidArgumentException If no element with the given name exists
+ */
+ public function getElement($name)
+ {
+ if (! array_key_exists($name, $this->elements)) {
+ throw new InvalidArgumentException(sprintf(
+ "Can't get element '%s'. Element does not exist",
+ $name
+ ));
+ }
+
+ return $this->elements[$name];
+ }
+
+ /**
+ * Add an element
+ *
+ * @param string|FormElement $typeOrElement Type of the element as string or an instance of FormElement
+ * @param string $name Name of the element
+ * @param mixed $options Element options as key-value pairs
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If $typeOrElement is neither a string nor an instance of FormElement
+ * or if $typeOrElement is a string and $name is not set
+ * or if $typeOrElement is a string but type is unknown
+ * or if $typeOrElement is an instance of FormElement but does not have a name
+ */
+ public function addElement($typeOrElement, $name = null, $options = null)
+ {
+ if (is_string($typeOrElement)) {
+ if ($name === null) {
+ throw new InvalidArgumentException(sprintf(
+ '%s expects parameter 2 to be set if parameter 1 is a string',
+ __METHOD__
+ ));
+ }
+
+ $element = $this->createElement($typeOrElement, $name, $options);
+ } elseif ($typeOrElement instanceof FormElement) {
+ $element = $typeOrElement;
+ } else {
+ throw new InvalidArgumentException(sprintf(
+ '%s() expects parameter 1 to be a string or an instance of %s, %s given',
+ __METHOD__,
+ FormElement::class,
+ get_php_type($typeOrElement)
+ ));
+ }
+
+ $this
+ ->registerElement($element) // registerElement() must be called first because of the name check
+ ->decorate($element)
+ ->addHtml($element);
+
+ return $this;
+ }
+
+ /**
+ * Create an element
+ *
+ * @param string $type Type of the element
+ * @param string $name Name of the element
+ * @param mixed $options Element options as key-value pairs
+ *
+ * @return FormElement
+ *
+ * @throws InvalidArgumentException If the type of the element is unknown
+ */
+ public function createElement($type, $name, $options = null)
+ {
+ $this->ensureDefaultElementLoaderRegistered();
+
+ $class = $this->loadPlugin('element', $type);
+
+ if (! $class) {
+ throw new InvalidArgumentException(sprintf(
+ "Can't create element of unknown type '%s",
+ $type
+ ));
+ }
+
+ /** @var FormElement $element */
+ $element = new $class($name);
+
+ if ($options !== null) {
+ $element->addAttributes($options);
+ }
+
+ return $element;
+ }
+
+ /**
+ * Register an element
+ *
+ * Registers the element for value and validation handling but does not add it to the render stack.
+ *
+ * @param FormElement $element
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If $element does not provide a name
+ */
+ public function registerElement(FormElement $element)
+ {
+ $name = $element->getName();
+
+ if ($name === null) {
+ throw new InvalidArgumentException(sprintf(
+ '%s expects the element to provide a name',
+ __METHOD__
+ ));
+ }
+
+ $this->elements[$name] = $element;
+
+ if (array_key_exists($name, $this->populatedValues)) {
+ $element->setValue($this->populatedValues[$name][count($this->populatedValues[$name]) - 1]);
+
+ if ($element instanceof ValueCandidates) {
+ $element->setValueCandidates($this->populatedValues[$name]);
+ }
+ }
+
+ $this->onElementRegistered($element);
+ $this->emit(Form::ON_ELEMENT_REGISTERED, [$element]);
+
+ return $this;
+ }
+
+ /**
+ * Get whether a default element decorator exists
+ *
+ * @return bool
+ */
+ public function hasDefaultElementDecorator()
+ {
+ return $this->defaultElementDecorator !== null;
+ }
+
+ /**
+ * Get the default element decorator, if any
+ *
+ * @return FormElementDecorator|null
+ */
+ public function getDefaultElementDecorator()
+ {
+ return $this->defaultElementDecorator;
+ }
+
+ /**
+ * Set the default element decorator
+ *
+ * If $decorator is a string, the decorator will be automatically created from a registered decorator loader.
+ * A loader for the namespace ipl\\Html\\FormDecorator is automatically registered by default.
+ * See {@link addDecoratorLoader()} for registering a custom loader.
+ *
+ * @param FormElementDecorator|string $decorator
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If $decorator is a string and can't be loaded from registered decorator loaders
+ * or if a decorator loader does not return an instance of
+ * {@link FormElementDecorator}
+ */
+ public function setDefaultElementDecorator($decorator)
+ {
+ if ($decorator instanceof FormElementDecorator || $decorator instanceof DecoratorInterface) {
+ $this->defaultElementDecorator = $decorator;
+ } else {
+ $this->ensureDefaultElementDecoratorLoaderRegistered();
+
+ $class = $this->loadPlugin('decorator', $decorator);
+ if (! $class) {
+ throw new InvalidArgumentException(sprintf(
+ "Can't create decorator of unknown type '%s",
+ $decorator
+ ));
+ }
+
+ $d = new $class();
+ if (! $d instanceof FormElementDecorator && ! $d instanceof DecoratorInterface) {
+ throw new InvalidArgumentException(sprintf(
+ "Expected instance of %s for decorator '%s',"
+ . " got %s from a decorator loader instead",
+ FormElementDecorator::class,
+ $decorator,
+ get_php_type($d)
+ ));
+ }
+
+ $this->defaultElementDecorator = $d;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get the value of the element specified by name
+ *
+ * Returns $default if the element does not exist or has no value.
+ *
+ * @param string $name
+ * @param mixed $default
+ *
+ * @return mixed
+ */
+ public function getValue($name, $default = null)
+ {
+ if ($this->hasElement($name)) {
+ $value = $this->getElement($name)->getValue();
+ if ($value !== null) {
+ return $value;
+ }
+ }
+
+ return $default;
+ }
+
+ /**
+ * Get the values for all but ignored elements
+ *
+ * @return array Values as name-value pairs
+ */
+ public function getValues()
+ {
+ $values = [];
+ foreach ($this->getElements() as $element) {
+ if (! $element->isIgnored()) {
+ $values[$element->getName()] = $element->getValue();
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Populate values of registered elements
+ *
+ * @param iterable<string, mixed> $values Values as name-value pairs
+ *
+ * @return $this
+ */
+ public function populate($values)
+ {
+ foreach ($values as $name => $value) {
+ $this->populatedValues[$name][] = $value;
+ if ($this->hasElement($name)) {
+ $this->getElement($name)->setValue($value);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get the populated value of the element specified by name
+ *
+ * Returns $default if there is no populated value for this element.
+ *
+ * @param string $name
+ * @param mixed $default
+ *
+ * @return mixed
+ */
+ public function getPopulatedValue($name, $default = null)
+ {
+ return isset($this->populatedValues[$name])
+ ? $this->populatedValues[$name][count($this->populatedValues[$name]) - 1]
+ : $default;
+ }
+
+ /**
+ * Clear populated value of the given element
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function clearPopulatedValue($name)
+ {
+ if (isset($this->populatedValues[$name])) {
+ unset($this->populatedValues[$name]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add all elements from the given element collection
+ *
+ * @param Form $form
+ *
+ * @return $this
+ */
+ public function addElementsFrom($form)
+ {
+ foreach ($form->getElements() as $element) {
+ $this->addElement($element);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a decorator loader
+ *
+ * @param string $namespace Namespace of the decorators
+ * @param string $postfix Decorator name postfix, if any
+ *
+ * @return $this
+ */
+ public function addDecoratorLoader($namespace, $postfix = null)
+ {
+ $this->addPluginLoader('decorator', $namespace, $postfix);
+
+ return $this;
+ }
+
+ /**
+ * Add an element loader
+ *
+ * @param string $namespace Namespace of the elements
+ * @param string $postfix Element name postfix, if any
+ *
+ * @return $this
+ */
+ public function addElementLoader($namespace, $postfix = null)
+ {
+ $this->addPluginLoader('element', $namespace, $postfix);
+
+ return $this;
+ }
+
+ /**
+ * Ensure that our default element decorator loader is registered
+ *
+ * @return $this
+ */
+ protected function ensureDefaultElementDecoratorLoaderRegistered()
+ {
+ if (! $this->defaultElementDecoratorLoaderRegistered) {
+ $this->addDefaultPluginLoader(
+ 'decorator',
+ 'ipl\\Html\\FormDecorator',
+ 'Decorator'
+ );
+
+ $this->defaultElementDecoratorLoaderRegistered = true;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Ensure that our default element loader is registered
+ *
+ * @return $this
+ */
+ protected function ensureDefaultElementLoaderRegistered()
+ {
+ if (! $this->defaultElementLoaderRegistered) {
+ $this->addDefaultPluginLoader('element', __NAMESPACE__, 'Element');
+
+ $this->defaultElementLoaderRegistered = true;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Decorate the given element
+ *
+ * @param FormElement $element
+ *
+ * @return $this
+ *
+ * @throws UnexpectedValueException If the default decorator is set but not an instance of
+ * {@link FormElementDecorator}
+ */
+ protected function decorate(FormElement $element)
+ {
+ if ($this->hasDefaultElementDecorator()) {
+ $decorator = $this->getDefaultElementDecorator();
+
+ if (! $decorator instanceof FormElementDecorator && ! $decorator instanceof DecoratorInterface) {
+ throw new UnexpectedValueException(sprintf(
+ '%s expects the default decorator to be an instance of %s, got %s instead',
+ __METHOD__,
+ FormElementDecorator::class,
+ get_php_type($decorator)
+ ));
+ }
+
+ $decorator->decorate($element);
+ }
+
+ return $this;
+ }
+
+ public function isValidEvent($event)
+ {
+ return in_array($event, [
+ Form::ON_SUCCESS,
+ Form::ON_SENT,
+ Form::ON_ERROR,
+ Form::ON_REQUEST,
+ Form::ON_VALIDATE,
+ Form::ON_ELEMENT_REGISTERED,
+ ]);
+ }
+
+ public function remove(ValidHtml $elementOrHtml)
+ {
+ if ($elementOrHtml instanceof FormElement) {
+ if ($this->hasElement($elementOrHtml)) {
+ $name = array_search($elementOrHtml, $this->elements, true);
+ if ($name !== false) {
+ unset($this->elements[$name]);
+ }
+ }
+ }
+
+ return parent::remove($elementOrHtml);
+ }
+
+ /**
+ * Handler which is called after an element has been registered
+ *
+ * @param FormElement $element
+ */
+ protected function onElementRegistered(FormElement $element)
+ {
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/HiddenElement.php b/vendor/ipl/html/src/FormElement/HiddenElement.php
new file mode 100644
index 0000000..bffc7eb
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/HiddenElement.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+class HiddenElement extends InputElement
+{
+ protected $type = 'hidden';
+}
diff --git a/vendor/ipl/html/src/FormElement/InputElement.php b/vendor/ipl/html/src/FormElement/InputElement.php
new file mode 100644
index 0000000..d5f945d
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/InputElement.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use ipl\Html\Attribute;
+use ipl\Html\Attributes;
+
+class InputElement extends BaseFormElement
+{
+ /** @var string Type of the input */
+ protected $type;
+
+ protected $tag = 'input';
+
+ /**
+ * Get the type of the input
+ *
+ * @return string
+ */
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ /**
+ * Set the type of the input
+ *
+ * @param string $type
+ *
+ * @return $this
+ */
+ public function setType($type)
+ {
+ $this->type = (string) $type;
+
+ return $this;
+ }
+
+ protected function registerAttributeCallbacks(Attributes $attributes)
+ {
+ parent::registerAttributeCallbacks($attributes);
+
+ $attributes->registerAttributeCallback(
+ 'type',
+ [$this, 'getType'],
+ [$this, 'setType']
+ );
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/LocalDateTimeElement.php b/vendor/ipl/html/src/FormElement/LocalDateTimeElement.php
new file mode 100644
index 0000000..a628b57
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/LocalDateTimeElement.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use DateTime;
+use ipl\Validator\DateTimeValidator;
+use ipl\Validator\ValidatorChain;
+
+class LocalDateTimeElement extends InputElement
+{
+ public const FORMAT = 'Y-m-d\TH:i:s';
+
+ protected $type = 'datetime-local';
+
+ protected $defaultAttributes = ['step' => '1'];
+
+ /** @var DateTime */
+ protected $value;
+
+ public function setValue($value)
+ {
+ if (is_string($value)) {
+ $originalVal = $value;
+ $value = DateTime::createFromFormat(static::FORMAT, $value);
+ // In Chrome, if the seconds are set to 00, DateTime::createFromFormat() returns false.
+ // Create DateTime without seconds in format
+ if ($value === false) {
+ $format = substr(static::FORMAT, 0, strrpos(static::FORMAT, ':') ?: null);
+ $value = DateTime::createFromFormat($format, $originalVal);
+ }
+
+ if ($value === false) {
+ $value = $originalVal;
+ }
+ }
+
+ return parent::setValue($value);
+ }
+
+ public function getValueAttribute()
+ {
+ if (! $this->value instanceof DateTime) {
+ return $this->value;
+ }
+
+ return $this->value->format(static::FORMAT);
+ }
+
+ protected function addDefaultValidators(ValidatorChain $chain): void
+ {
+ $chain->add(new DateTimeValidator());
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/NumberElement.php b/vendor/ipl/html/src/FormElement/NumberElement.php
new file mode 100644
index 0000000..b593135
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/NumberElement.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+class NumberElement extends InputElement
+{
+ protected $type = 'number';
+}
diff --git a/vendor/ipl/html/src/FormElement/PasswordElement.php b/vendor/ipl/html/src/FormElement/PasswordElement.php
new file mode 100644
index 0000000..dfa6d8c
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/PasswordElement.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use ipl\Html\Attributes;
+use ipl\Html\Form;
+
+class PasswordElement extends InputElement
+{
+ /** @var string Dummy passwd of this element to be rendered */
+ public const DUMMYPASSWORD = '_ipl_form_5847ed1b5b8ca';
+
+ protected $type = 'password';
+
+ /** @var bool Status of the form */
+ protected $isFormValid = true;
+
+ protected function registerAttributeCallbacks(Attributes $attributes)
+ {
+ parent::registerAttributeCallbacks($attributes);
+
+ $attributes->registerAttributeCallback(
+ 'value',
+ function () {
+ if ($this->hasValue() && count($this->getValueCandidates()) === 1 && $this->isFormValid) {
+ return self::DUMMYPASSWORD;
+ }
+
+ if (parent::getValue() === self::DUMMYPASSWORD) {
+ return self::DUMMYPASSWORD;
+ }
+
+ return null;
+ }
+ );
+ }
+
+ public function onRegistered(Form $form)
+ {
+ $form->on(Form::ON_VALIDATE, function ($form) {
+ $this->isFormValid = $form->isValid();
+ });
+ }
+
+ public function getValue()
+ {
+ $value = parent::getValue();
+ $candidates = $this->getValueCandidates();
+ while ($value === self::DUMMYPASSWORD) {
+ $value = array_pop($candidates);
+ }
+
+ return $value;
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/RadioElement.php b/vendor/ipl/html/src/FormElement/RadioElement.php
new file mode 100644
index 0000000..831671c
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/RadioElement.php
@@ -0,0 +1,177 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use InvalidArgumentException;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use ipl\Validator\DeferredInArrayValidator;
+use ipl\Validator\ValidatorChain;
+
+class RadioElement extends BaseFormElement
+{
+ use Translation;
+
+ /** @var string The element type */
+ protected $type = 'radio';
+
+ /** @var RadioOption[] Radio options */
+ protected $options = [];
+
+ /** @var array Disabled radio options */
+ protected $disabledOptions = [];
+
+ /**
+ * Set the options
+ *
+ * @param array $options
+ *
+ * @return $this
+ */
+ public function setOptions(array $options): self
+ {
+ $this->options = [];
+ foreach ($options as $value => $label) {
+ $option = (new RadioOption($value, $label))
+ ->setDisabled(
+ in_array($value, $this->disabledOptions, ! is_int($value))
+ || ($value === '' && in_array(null, $this->disabledOptions, true))
+ );
+
+ $this->options[$value] = $option;
+ }
+
+ $this->disabledOptions = [];
+
+ return $this;
+ }
+
+ /**
+ * Get the option with specified value
+ *
+ * @param string|int $value
+ *
+ * @return RadioOption
+ *
+ * @throws InvalidArgumentException If no option with the specified value exists
+ */
+ public function getOption($value): RadioOption
+ {
+ if (! isset($this->options[$value])) {
+ throw new InvalidArgumentException(sprintf('There is no such option "%s"', $value));
+ }
+
+ return $this->options[$value];
+ }
+
+ /**
+ * Set the specified options as disable
+ *
+ * @param array $disabledOptions
+ *
+ * @return $this
+ */
+ public function setDisabledOptions(array $disabledOptions): self
+ {
+ if (! empty($this->options)) {
+ foreach ($this->options as $value => $option) {
+ $option->setDisabled(
+ in_array($value, $disabledOptions, ! is_int($value))
+ || ($value === '' && in_array(null, $disabledOptions, true))
+ );
+ }
+
+ $this->disabledOptions = [];
+ } else {
+ $this->disabledOptions = $disabledOptions;
+ }
+
+ return $this;
+ }
+
+ public function renderUnwrapped()
+ {
+ // Parent::renderUnwrapped() requires $tag and the content should be empty. However, since we are wrapping
+ // each button in a label, the call to parent cannot work here and must be overridden.
+ return HtmlDocument::renderUnwrapped();
+ }
+
+ protected function assemble()
+ {
+ foreach ($this->options as $option) {
+ $radio = (new InputElement($this->getValueOfNameAttribute()))
+ ->setType($this->type)
+ ->setValue($option->getValue());
+
+ // Only add the non-callback attributes to all options
+ foreach ($this->getAttributes() as $attribute) {
+ $radio->getAttributes()->addAttribute(clone $attribute);
+ }
+
+ $radio->getAttributes()
+ ->merge($option->getAttributes())
+ ->registerAttributeCallback(
+ 'checked',
+ function () use ($option) {
+ $optionValue = $option->getValue();
+
+ return ! is_int($optionValue)
+ ? $this->getValue() === $optionValue
+ : $this->getValue() == $optionValue;
+ }
+ )
+ ->registerAttributeCallback(
+ 'disabled',
+ function () use ($option) {
+ return $this->getAttributes()->get('disabled')->getValue() || $option->isDisabled();
+ }
+ );
+
+ $label = new HtmlElement(
+ 'label',
+ new Attributes(['class' => $option->getLabelCssClass()]),
+ $radio,
+ Text::create($option->getLabel())
+ );
+
+ $this->addHtml($label);
+ }
+ }
+
+ protected function addDefaultValidators(ValidatorChain $chain): void
+ {
+ $chain->add(new DeferredInArrayValidator(function (): array {
+ $possibleValues = [];
+
+ foreach ($this->options as $option) {
+ if ($option->isDisabled()) {
+ continue;
+ }
+
+ $possibleValues[] = $option->getValue();
+ }
+
+ return $possibleValues;
+ }));
+ }
+
+ protected function registerAttributeCallbacks(Attributes $attributes)
+ {
+ parent::registerAttributeCallbacks($attributes);
+
+ $this->getAttributes()->registerAttributeCallback(
+ 'options',
+ null,
+ [$this, 'setOptions']
+ );
+
+ $this->getAttributes()->registerAttributeCallback(
+ 'disabledOptions',
+ null,
+ [$this, 'setDisabledOptions']
+ );
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/RadioOption.php b/vendor/ipl/html/src/FormElement/RadioOption.php
new file mode 100644
index 0000000..4968c35
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/RadioOption.php
@@ -0,0 +1,148 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use ipl\Html\Attributes;
+
+class RadioOption
+{
+ /** @var string The default label class */
+ public const LABEL_CLASS = 'radio-label';
+
+ /** @var string|int|null Value of the option */
+ protected $value;
+
+ /** @var string Label of the option */
+ protected $label;
+
+ /** @var mixed Css class of the option's label */
+ protected $labelCssClass = self::LABEL_CLASS;
+
+ /** @var bool Whether the radio option is disabled */
+ protected $disabled = false;
+
+ /** @var Attributes */
+ protected $attributes;
+
+ /**
+ * RadioOption constructor.
+ *
+ * @param string|int|null $value
+ * @param string $label
+ */
+ public function __construct($value, string $label)
+ {
+ $this->value = $value === '' ? null : $value;
+ $this->label = $label;
+ }
+
+ /**
+ * Set the label of the option
+ *
+ * @param string $label
+ *
+ * @return $this
+ */
+ public function setLabel(string $label): self
+ {
+ $this->label = $label;
+
+ return $this;
+ }
+
+ /**
+ * Get the label of the option
+ *
+ * @return string
+ */
+ public function getLabel(): string
+ {
+ return $this->label;
+ }
+
+ /**
+ * Get the value of the option
+ *
+ * @return string|int|null
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ /**
+ * Set css class to the option label
+ *
+ * @param string|string[] $labelCssClass
+ *
+ * @return $this
+ */
+ public function setLabelCssClass($labelCssClass): self
+ {
+ $this->labelCssClass = $labelCssClass;
+
+ return $this;
+ }
+
+ /**
+ * Get css class of the option label
+ *
+ * @return string|string[]
+ */
+ public function getLabelCssClass()
+ {
+ return $this->labelCssClass;
+ }
+
+ /**
+ * Set whether to disable the option
+ *
+ * @param bool $disabled
+ *
+ * @return $this
+ */
+ public function setDisabled(bool $disabled = true): self
+ {
+ $this->disabled = $disabled;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the option is disabled
+ *
+ * @return bool
+ */
+ public function isDisabled(): bool
+ {
+ return $this->disabled;
+ }
+
+ /**
+ * Add the attributes
+ *
+ * @param Attributes $attributes
+ *
+ * @return $this
+ */
+ public function addAttributes(Attributes $attributes): self
+ {
+ $this->attributes = $attributes;
+
+ return $this;
+ }
+
+ /**
+ * Get the attributes
+ *
+ * @return Attributes
+ */
+ public function getAttributes(): Attributes
+ {
+ if ($this->attributes === null) {
+ $this->attributes = new Attributes();
+ }
+
+ return $this->attributes;
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/SelectElement.php b/vendor/ipl/html/src/FormElement/SelectElement.php
new file mode 100644
index 0000000..e6b4f21
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/SelectElement.php
@@ -0,0 +1,238 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use InvalidArgumentException;
+use ipl\Html\Attributes;
+use ipl\Html\Common\MultipleAttribute;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Validator\DeferredInArrayValidator;
+use ipl\Validator\ValidatorChain;
+use UnexpectedValueException;
+
+class SelectElement extends BaseFormElement
+{
+ use MultipleAttribute;
+
+ protected $tag = 'select';
+
+ /** @var SelectOption[] */
+ protected $options = [];
+
+ /** @var SelectOption[]|HtmlElement[] */
+ protected $optionContent = [];
+
+ /** @var array Disabled select options */
+ protected $disabledOptions = [];
+
+ /** @var array|string|null */
+ protected $value;
+
+ /**
+ * Get the option with specified value
+ *
+ * @param string|int|null $value
+ *
+ * @return ?SelectOption
+ */
+ public function getOption($value): ?SelectOption
+ {
+ return $this->options[$value] ?? null;
+ }
+
+ /**
+ * Set the options from specified values
+ *
+ * @param array $options
+ *
+ * @return $this
+ */
+ public function setOptions(array $options): self
+ {
+ $this->options = [];
+ $this->optionContent = [];
+ foreach ($options as $value => $label) {
+ $this->optionContent[$value] = $this->makeOption($value, $label);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set the specified options as disable
+ *
+ * @param array $disabledOptions
+ *
+ * @return $this
+ */
+ public function setDisabledOptions(array $disabledOptions): self
+ {
+ if (! empty($this->options)) {
+ foreach ($this->options as $option) {
+ $optionValue = $option->getValue();
+
+ $option->setAttribute(
+ 'disabled',
+ in_array($optionValue, $disabledOptions, ! is_int($optionValue))
+ || ($optionValue === null && in_array('', $disabledOptions, true))
+ );
+ }
+
+ $this->disabledOptions = [];
+ } else {
+ $this->disabledOptions = $disabledOptions;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get the value of the element
+ *
+ * Returns `array` when the attribute `multiple` is set to `true`, `string` or `null` otherwise
+ *
+ * @return array|string|null
+ */
+ public function getValue()
+ {
+ if ($this->isMultiple()) {
+ return parent::getValue() ?? [];
+ }
+
+ return parent::getValue();
+ }
+
+ public function getValueAttribute()
+ {
+ // select elements don't have a value attribute
+ return null;
+ }
+
+ public function getNameAttribute()
+ {
+ $name = $this->getName();
+
+ return $this->isMultiple() ? ($name . '[]') : $name;
+ }
+
+ /**
+ * Make the selectOption for the specified value and the label
+ *
+ * @param string|int|null $value Value of the option
+ * @param string|array $label Label of the option
+ *
+ * @return SelectOption|HtmlElement
+ */
+ protected function makeOption($value, $label)
+ {
+ if (is_array($label)) {
+ $grp = Html::tag('optgroup', ['label' => $value]);
+ foreach ($label as $option => $val) {
+ $grp->addHtml($this->makeOption($option, $val));
+ }
+
+ return $grp;
+ }
+
+ $option = (new SelectOption($value, $label))
+ ->setAttribute('disabled', in_array($value, $this->disabledOptions, ! is_int($value)));
+
+ $option->getAttributes()->registerAttributeCallback('selected', function () use ($option) {
+ return $this->isSelectedOption($option->getValue());
+ });
+
+ $this->options[$value] = $option;
+
+ return $this->options[$value];
+ }
+
+ /**
+ * Get whether the given option is selected
+ *
+ * @param int|string|null $optionValue
+ *
+ * @return bool
+ */
+ protected function isSelectedOption($optionValue): bool
+ {
+ $value = $this->getValue();
+
+ if ($optionValue === '') {
+ $optionValue = null;
+ }
+
+ if ($this->isMultiple()) {
+ if (! is_array($value)) {
+ throw new UnexpectedValueException(
+ 'Value must be an array when the `multiple` attribute is set to `true`'
+ );
+ }
+
+ return in_array($optionValue, $this->getValue(), ! is_int($optionValue))
+ || ($optionValue === null && in_array('', $this->getValue(), true));
+ }
+
+ if (is_array($value)) {
+ throw new UnexpectedValueException(
+ 'Value cannot be an array without setting the `multiple` attribute to `true`'
+ );
+ }
+
+ return is_int($optionValue)
+ // The loose comparison is required because PHP casts
+ // numeric strings to integers if used as array keys
+ ? $value == $optionValue
+ : $value === $optionValue;
+ }
+
+ protected function addDefaultValidators(ValidatorChain $chain): void
+ {
+ $chain->add(
+ new DeferredInArrayValidator(function (): array {
+ $possibleValues = [];
+
+ foreach ($this->options as $option) {
+ if ($option->getAttributes()->get('disabled')->getValue()) {
+ continue;
+ }
+
+ $possibleValues[] = $option->getValue();
+ }
+
+ return $possibleValues;
+ })
+ );
+ }
+
+ protected function assemble()
+ {
+ $this->addHtml(...array_values($this->optionContent));
+ }
+
+ protected function registerAttributeCallbacks(Attributes $attributes)
+ {
+ parent::registerAttributeCallbacks($attributes);
+
+ $attributes->registerAttributeCallback(
+ 'options',
+ null,
+ [$this, 'setOptions']
+ );
+
+ $attributes->registerAttributeCallback(
+ 'disabledOptions',
+ null,
+ [$this, 'setDisabledOptions']
+ );
+
+ // ZF1 compatibility:
+ $this->getAttributes()->registerAttributeCallback(
+ 'multiOptions',
+ null,
+ [$this, 'setOptions']
+ );
+
+ $this->registerMultipleAttributeCallback($attributes);
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/SelectOption.php b/vendor/ipl/html/src/FormElement/SelectOption.php
new file mode 100644
index 0000000..3d799a2
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/SelectOption.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use ipl\Html\BaseHtmlElement;
+
+class SelectOption extends BaseHtmlElement
+{
+ protected $tag = 'option';
+
+ /** @var string|int|null Value of the option */
+ protected $value;
+
+ /** @var string Label of the option */
+ protected $label;
+
+ /**
+ * SelectOption constructor.
+ *
+ * @param string|int|null $value
+ * @param string $label
+ */
+ public function __construct($value, string $label)
+ {
+ $this->value = $value === '' ? null : $value;
+ $this->label = $label;
+
+ $this->getAttributes()->registerAttributeCallback('value', [$this, 'getValueAttribute']);
+ }
+
+ /**
+ * Set the label of the option
+ *
+ * @param string $label
+ *
+ * @return $this
+ */
+ public function setLabel(string $label): self
+ {
+ $this->label = $label;
+
+ return $this;
+ }
+
+ /**
+ * Get the label of the option
+ *
+ * @return string
+ */
+ public function getLabel(): string
+ {
+ return $this->label;
+ }
+
+ /**
+ * Get the value of the option
+ *
+ * @return string|int|null
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ /**
+ * Callback for the value attribute
+ *
+ * @return mixed
+ */
+ public function getValueAttribute()
+ {
+ return (string) $this->getValue();
+ }
+
+ protected function assemble()
+ {
+ $this->setContent($this->getLabel());
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/SubmitButtonElement.php b/vendor/ipl/html/src/FormElement/SubmitButtonElement.php
new file mode 100644
index 0000000..b880bb5
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/SubmitButtonElement.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use ipl\Html\Attributes;
+use ipl\Html\Contract\FormSubmitElement;
+
+class SubmitButtonElement extends ButtonElement implements FormSubmitElement
+{
+ protected $defaultAttributes = ['type' => 'submit'];
+
+ /** @var string The value that's transmitted once the button is pressed */
+ protected $submitValue = 'y';
+
+ /**
+ * Get the value to transmit once the button is pressed
+ *
+ * @return string
+ */
+ public function getSubmitValue(): string
+ {
+ return $this->submitValue;
+ }
+
+ /**
+ * Set the value to transmit once the button is pressed
+ *
+ * @param string $value
+ *
+ * @return $this
+ */
+ public function setSubmitValue(string $value): self
+ {
+ $this->submitValue = $value;
+
+ return $this;
+ }
+
+ public function setLabel($label)
+ {
+ return $this->setContent($label);
+ }
+
+ public function hasBeenPressed()
+ {
+ return $this->getValue() === $this->getSubmitValue();
+ }
+
+ public function isIgnored()
+ {
+ return true;
+ }
+
+ protected function registerAttributeCallbacks(Attributes $attributes)
+ {
+ parent::registerAttributeCallbacks($attributes);
+
+ $attributes->registerAttributeCallback('value', null, [$this, 'setSubmitValue']);
+ }
+
+ public function getValueAttribute()
+ {
+ return $this->submitValue;
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/SubmitElement.php b/vendor/ipl/html/src/FormElement/SubmitElement.php
new file mode 100644
index 0000000..51d4aa5
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/SubmitElement.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+use ipl\Html\Attribute;
+use ipl\Html\Contract\FormSubmitElement;
+
+class SubmitElement extends InputElement implements FormSubmitElement
+{
+ protected $type = 'submit';
+
+ protected $buttonLabel;
+
+ public function setLabel($label)
+ {
+ $this->buttonLabel = $label;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getButtonLabel()
+ {
+ if ($this->buttonLabel === null) {
+ return $this->getName();
+ } else {
+ return $this->buttonLabel;
+ }
+ }
+
+ /**
+ * @return mixed|static
+ */
+ public function getValueAttribute()
+ {
+ return new Attribute('value', $this->getButtonLabel());
+ }
+
+ public function hasBeenPressed()
+ {
+ return $this->getButtonLabel() === $this->getValue();
+ }
+
+ public function isIgnored()
+ {
+ return true;
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/TextElement.php b/vendor/ipl/html/src/FormElement/TextElement.php
new file mode 100644
index 0000000..0e3423d
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/TextElement.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+class TextElement extends InputElement
+{
+ protected $type = 'text';
+}
diff --git a/vendor/ipl/html/src/FormElement/TextareaElement.php b/vendor/ipl/html/src/FormElement/TextareaElement.php
new file mode 100644
index 0000000..dc5c42b
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/TextareaElement.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+class TextareaElement extends BaseFormElement
+{
+ protected $tag = 'textarea';
+
+ public function setValue($value)
+ {
+ parent::setValue($value);
+
+ // A textarea's content actually is the value
+ $this->setContent($value);
+
+ return $this;
+ }
+
+ public function getValueAttribute()
+ {
+ // textarea elements don't have a value attribute
+ return null;
+ }
+}
diff --git a/vendor/ipl/html/src/FormElement/TimeElement.php b/vendor/ipl/html/src/FormElement/TimeElement.php
new file mode 100644
index 0000000..1ee0323
--- /dev/null
+++ b/vendor/ipl/html/src/FormElement/TimeElement.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace ipl\Html\FormElement;
+
+class TimeElement extends InputElement
+{
+ protected $type = 'time';
+}
diff --git a/vendor/ipl/html/src/FormattedString.php b/vendor/ipl/html/src/FormattedString.php
new file mode 100644
index 0000000..1ef9b5b
--- /dev/null
+++ b/vendor/ipl/html/src/FormattedString.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace ipl\Html;
+
+use Exception;
+use InvalidArgumentException;
+
+use function ipl\Stdlib\get_php_type;
+
+/**
+ * {@link sprintf()}-like formatted HTML string supporting lazy rendering of {@link ValidHtml} element arguments
+ *
+ * # Example Usage
+ * ```
+ * $info = new FormattedString(
+ * 'Follow the %s for more information on %s',
+ * [
+ * new Link('doc/html', 'HTML documentation'),
+ * Html::tag('strong', 'HTML elements')
+ * ]
+ * );
+ * ```
+ */
+class FormattedString implements ValidHtml
+{
+ /** @var ValidHtml[] */
+ protected $args = [];
+
+ /** @var ValidHtml */
+ protected $format;
+
+ /**
+ * Create a new {@link sprintf()}-like formatted HTML string
+ *
+ * @param string $format
+ * @param iterable $args
+ *
+ * @throws InvalidArgumentException If arguments given but not iterable
+ */
+ public function __construct($format, $args = null)
+ {
+ $this->format = Html::wantHtml($format);
+
+ if ($args !== null) {
+ if (! is_iterable($args)) {
+ throw new InvalidArgumentException(sprintf(
+ '%s expects parameter two to be iterable, got %s instead',
+ __METHOD__,
+ get_php_type($args)
+ ));
+ }
+
+ foreach ($args as $key => $val) {
+ if (! is_scalar($val) || (is_string($val) && ! is_numeric($val))) {
+ $val = Html::wantHtml($val);
+ }
+
+ $this->args[$key] = $val;
+ }
+ }
+ }
+
+
+ /**
+ * Create a new {@link sprintf()}-like formatted HTML string
+ *
+ * @param string $format
+ * @param mixed ...$args
+ *
+ * @return static
+ */
+ public static function create($format, ...$args)
+ {
+ return new static($format, $args);
+ }
+
+ /**
+ * Render text to HTML when treated like a string
+ *
+ * Calls {@link render()} internally in order to render the text to HTML.
+ * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}.
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return Error::render($e);
+ }
+ }
+
+ public function render()
+ {
+ return vsprintf(
+ $this->format->render(),
+ $this->args
+ );
+ }
+}
diff --git a/vendor/ipl/html/src/Html.php b/vendor/ipl/html/src/Html.php
new file mode 100644
index 0000000..afcba39
--- /dev/null
+++ b/vendor/ipl/html/src/Html.php
@@ -0,0 +1,241 @@
+<?php
+
+namespace ipl\Html;
+
+use InvalidArgumentException;
+
+use function ipl\Stdlib\get_php_type;
+use function ipl\Stdlib\iterable_key_first;
+
+/**
+ * Main utility class when working with ipl\Html
+ */
+abstract class Html
+{
+ /**
+ * Create a HTML element from the given tag, attributes and content
+ *
+ * This method does not render the HTML element but creates a {@link HtmlElement}
+ * instance from the given tag, attributes and content
+ *
+ * @param string $name The desired HTML tag name
+ * @param mixed $attributes HTML attributes or content for the element
+ * @param mixed $content The content of the element if no attributes have been given
+ *
+ * @return HtmlElement The created element
+ */
+ public static function tag($name, $attributes = null, $content = null)
+ {
+ if ($content !== null) {
+ // If not null, it's html content, no question
+ $content = static::wantHtmlList($content);
+ } elseif ($attributes instanceof ValidHtml || is_scalar($attributes)) {
+ // Otherwise $attributes may be $content, but only if definitely **NOT** attributes
+ $content = static::wantHtmlList($attributes);
+ $attributes = null;
+ }
+
+ if ($attributes !== null) {
+ if (! is_iterable($attributes) || ! is_int(iterable_key_first($attributes))) {
+ // Not an array (e.g. instance of Attributes) or an associative array
+ $attributes = Attributes::wantAttributes($attributes);
+ } elseif (is_iterable($attributes)) {
+ // $attributes may still be $content, in case of a sequenced array
+ if ($content !== null) {
+ // But not if there's already $content
+ throw new InvalidArgumentException('Value of argument $attributes are no attributes');
+ }
+
+ $content = static::wantHtmlList($attributes);
+ $attributes = null;
+ }
+ }
+
+ return new HtmlElement($name, $attributes, ...($content ?: []));
+ }
+
+ /**
+ * Convert special characters to HTML5 entities using the UTF-8 character
+ * set for encoding
+ *
+ * This method internally uses {@link htmlspecialchars} with the following
+ * flags:
+ *
+ * * Single quotes are not escaped (ENT_COMPAT)
+ * * Uses HTML5 entities, disallowing &#013; (ENT_HTML5)
+ * * Invalid characters are replaced with � (ENT_SUBSTITUTE)
+ *
+ * Already existing HTML entities will be encoded as well.
+ *
+ * @param string $content The content to encode
+ *
+ * @return string The encoded content
+ */
+ public static function escape($content)
+ {
+ return htmlspecialchars($content, ENT_COMPAT | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
+ }
+
+ /**
+ * Factory for {@link sprintf()}-like formatted HTML strings
+ *
+ * This allows to use {@link sprintf()}-like format strings with {@link ValidHtml} element arguments, but with the
+ * advantage that they'll not be rendered immediately.
+ *
+ * # Example Usage
+ * ```
+ * echo Html::sprintf('Hello %s!', Html::tag('strong', $name));
+ * ```
+ *
+ * @param string $format
+ * @param mixed ...$args
+ *
+ * @return FormattedString
+ */
+ public static function sprintf($format, ...$args)
+ {
+ return new FormattedString($format, $args);
+ }
+
+ /**
+ * Wrap each item of then given list
+ *
+ * $wrapper is a simple HTML tag per entry if a string is given,
+ * otherwise the given callable is called with key and value of each list item as parameters.
+ *
+ * @param iterable $list
+ * @param string|callable $wrapper
+ *
+ * @return HtmlDocument
+ */
+ public static function wrapEach($list, $wrapper)
+ {
+ if (! is_iterable($list)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Html::wrapEach() requires a traversable list, got "%s"',
+ get_php_type($list)
+ ));
+ }
+ $result = new HtmlDocument();
+ foreach ($list as $name => $value) {
+ if (is_string($wrapper)) {
+ $result->addHtml(Html::tag($wrapper, $value));
+ } elseif (is_callable($wrapper)) {
+ $result->add($wrapper($name, $value));
+ } else {
+ throw new InvalidArgumentException(sprintf(
+ 'Wrapper must be callable or a string in Html::wrapEach(), got "%s"',
+ get_php_type($wrapper)
+ ));
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Ensure that the given content of mixed type is converted to an instance of {@link ValidHtml}
+ *
+ * Returns the very same element in case it's already an instance of {@link ValidHtml}.
+ *
+ * @param mixed $any
+ *
+ * @return ValidHtml
+ *
+ * @throws InvalidArgumentException In case the given content is of an unsupported type
+ */
+ public static function wantHtml($any)
+ {
+ if ($any instanceof ValidHtml) {
+ return $any;
+ } elseif (static::canBeRenderedAsString($any)) {
+ return new Text($any);
+ } elseif (is_iterable($any)) {
+ $html = new HtmlDocument();
+ foreach ($any as $el) {
+ if ($el !== null) {
+ $html->addHtml(static::wantHtml($el));
+ }
+ }
+
+ return $html;
+ } else {
+ throw new InvalidArgumentException(sprintf(
+ 'String, Html Element or Array of such expected, got "%s"',
+ get_php_type($any)
+ ));
+ }
+ }
+
+ /**
+ * Accept any input and return it as list of ValidHtml
+ *
+ * @param mixed $content
+ *
+ * @return ValidHtml[]
+ */
+ public static function wantHtmlList($content)
+ {
+ $list = [];
+
+ if ($content === null) {
+ return $list;
+ } elseif (! is_iterable($content)) {
+ $list[] = static::wantHtml($content);
+ } elseif ($content instanceof ValidHtml) {
+ $list[] = $content;
+ } else {
+ foreach ($content as $part) {
+ $list = array_merge($list, static::wantHtmlList($part));
+ }
+ }
+
+ return $list;
+ }
+
+ /**
+ * Get whether the given variable be rendered as a string
+ *
+ * @param mixed $any
+ *
+ * @return bool
+ */
+ public static function canBeRenderedAsString($any)
+ {
+ return is_scalar($any) || is_null($any) || (
+ is_object($any) && method_exists($any, '__toString')
+ );
+ }
+
+ /**
+ * Forward inaccessible static method calls to {@link Html::tag()} with the method's name as tag
+ *
+ * @param string $name
+ * @param array $arguments
+ *
+ * @return HtmlElement
+ */
+ public static function __callStatic($name, $arguments)
+ {
+ $attributes = array_shift($arguments);
+ $content = array_shift($arguments);
+
+ return static::tag($name, $attributes, $content);
+ }
+
+ /**
+ * @deprecated Use {@link Html::encode()} instead
+ */
+ public static function escapeForHtml($content)
+ {
+ return static::escape($content);
+ }
+
+ /**
+ * @deprecated Use {@link Error::render()} instead
+ */
+ public static function renderError($error)
+ {
+ return Error::render($error);
+ }
+}
diff --git a/vendor/ipl/html/src/HtmlDocument.php b/vendor/ipl/html/src/HtmlDocument.php
new file mode 100644
index 0000000..e4e977e
--- /dev/null
+++ b/vendor/ipl/html/src/HtmlDocument.php
@@ -0,0 +1,607 @@
+<?php
+
+namespace ipl\Html;
+
+use Countable;
+use Exception;
+use InvalidArgumentException;
+use ipl\Html\Contract\Wrappable;
+use ipl\Stdlib\Events;
+use RuntimeException;
+
+/**
+ * HTML document
+ *
+ * An HTML document is composed of a tree of HTML nodes, i.e. text nodes and HTML elements.
+ */
+class HtmlDocument implements Countable, Wrappable
+{
+ use Events;
+
+ /** @var string Emitted after the content has been assembled */
+ public const ON_ASSEMBLED = 'assembled';
+
+ /** @var string Content separator */
+ protected $contentSeparator = '';
+
+ /** @var bool Whether the document has been assembled */
+ protected $hasBeenAssembled = false;
+
+ /** @var Wrappable Wrapper */
+ protected $wrapper;
+
+ /** @var Wrappable Wrapped element */
+ private $wrapped;
+
+ /** @var HtmlDocument The currently responsible wrapper */
+ private $renderedBy;
+
+ /** @var ValidHtml[] Content */
+ private $content = [];
+
+ /** @var array */
+ private $contentIndex = [];
+
+ /**
+ * Set the element to wrap
+ *
+ * @param Wrappable $element
+ *
+ * @return $this
+ */
+ private function setWrapped(Wrappable $element)
+ {
+ $this->wrapped = $element;
+
+ return $this;
+ }
+
+ /**
+ * Consume the wrapped element
+ *
+ * @return Wrappable
+ */
+ private function consumeWrapped()
+ {
+ $wrapped = $this->wrapped;
+ $this->wrapped = null;
+
+ return $wrapped;
+ }
+
+ /**
+ * Get the content
+ *
+ * return ValidHtml[]
+ */
+ public function getContent()
+ {
+ return $this->content;
+ }
+
+ /**
+ * Set the content
+ *
+ * @param mixed $content
+ *
+ * @return $this
+ */
+ public function setContent($content)
+ {
+ $this->content = [];
+ $this->setHtmlContent(...Html::wantHtmlList($content));
+
+ return $this;
+ }
+
+ /**
+ * Set content
+ *
+ * @param ValidHtml ...$content
+ *
+ * @return $this
+ */
+ public function setHtmlContent(ValidHtml ...$content)
+ {
+ $this->content = [];
+ foreach ($content as $element) {
+ $this->addIndexedContent($element);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get the content separator
+ *
+ * @return string
+ */
+ public function getSeparator()
+ {
+ return $this->contentSeparator;
+ }
+
+ /**
+ * Set the content separator
+ *
+ * @param string $separator
+ *
+ * @return $this
+ */
+ public function setSeparator($separator)
+ {
+ $this->contentSeparator = $separator;
+
+ return $this;
+ }
+
+ /**
+ * Get the first {@link BaseHtmlElement} with the given tag
+ *
+ * @param string $tag
+ *
+ * @return BaseHtmlElement
+ *
+ * @throws InvalidArgumentException If no {@link BaseHtmlElement} with the given tag exists
+ */
+ public function getFirst($tag)
+ {
+ foreach ($this->content as $c) {
+ if ($c instanceof BaseHtmlElement && $c->getTag() === $tag) {
+ return $c;
+ }
+ }
+
+ throw new InvalidArgumentException(sprintf(
+ 'Trying to get first %s, but there is no such',
+ $tag
+ ));
+ }
+
+ /**
+ * Insert Html after an existing Html node
+ *
+ * @param ValidHtml $newNode
+ * @param ValidHtml $existingNode
+ *
+ * @return $this
+ */
+ public function insertAfter(ValidHtml $newNode, ValidHtml $existingNode): self
+ {
+ $index = array_search($existingNode, $this->content, true);
+ if ($index === false) {
+ throw new InvalidArgumentException('The content does not contain the $existingNode');
+ }
+
+ array_splice($this->content, (int) $index + 1, 0, [$newNode]);
+
+ $this->reIndexContent();
+
+ return $this;
+ }
+
+ /**
+ * Insert Html after an existing Html node
+ *
+ * @param ValidHtml $newNode
+ * @param ValidHtml $existingNode
+ *
+ * @return $this
+ */
+ public function insertBefore(ValidHtml $newNode, ValidHtml $existingNode): self
+ {
+ $index = array_search($existingNode, $this->content);
+ if ($index === false) {
+ throw new InvalidArgumentException('The content does not contain the $existingNode');
+ }
+
+ array_splice($this->content, (int) $index, 0, [$newNode]);
+
+ $this->reIndexContent();
+
+ return $this;
+ }
+
+ /**
+ * Add content
+ *
+ * @param mixed $content
+ *
+ * @return $this
+ */
+ public function add($content)
+ {
+ $this->addHtml(...Html::wantHtmlList($content));
+
+ return $this;
+ }
+
+ /**
+ * Add content
+ *
+ * @param ValidHtml ...$content
+ *
+ * @return $this
+ */
+ public function addHtml(ValidHtml ...$content)
+ {
+ foreach ($content as $element) {
+ $this->addIndexedContent($element);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add content from the given document
+ *
+ * @param HtmlDocument $from
+ * @param callable $callback Optional callback in order to transform the content to add
+ *
+ * @return $this
+ */
+ public function addFrom(HtmlDocument $from, $callback = null)
+ {
+ $from->ensureAssembled();
+
+ $isCallable = is_callable($callback);
+ foreach ($from->getContent() as $item) {
+ $this->add($isCallable ? $callback($item) : $item);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Check whether the given element is a direct or indirect child of this document
+ *
+ * A direct child is one that is part of this document's content. An indirect child
+ * is one that is part of a direct child's content (recursively).
+ *
+ * @param ValidHtml $element
+ *
+ * @return bool
+ */
+ public function contains(ValidHtml $element)
+ {
+ $key = spl_object_hash($element);
+ if (array_key_exists($key, $this->contentIndex)) {
+ return true;
+ }
+
+ foreach ($this->content as $child) {
+ if ($child instanceof self && $child->contains($element)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Prepend content
+ *
+ * @param mixed $content
+ *
+ * @return $this
+ */
+ public function prepend($content)
+ {
+ $this->prependHtml(...Html::wantHtmlList($content));
+
+ return $this;
+ }
+
+ /**
+ * Prepend content
+ *
+ * @param ValidHtml ...$content
+ *
+ * @return $this
+ */
+ public function prependHtml(ValidHtml ...$content)
+ {
+ foreach (array_reverse($content) as $html) {
+ array_unshift($this->content, $html);
+ $this->incrementIndexKeys();
+ $this->addObjectPosition($html, 0);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Remove content
+ *
+ * @param ValidHtml $html
+ *
+ * @return $this
+ */
+ public function remove(ValidHtml $html)
+ {
+ $key = spl_object_hash($html);
+ if (array_key_exists($key, $this->contentIndex)) {
+ foreach ($this->contentIndex[$key] as $pos) {
+ unset($this->content[$pos]);
+ }
+ }
+ $this->content = array_values($this->content);
+
+ $this->reIndexContent();
+
+ return $this;
+ }
+
+ /**
+ * Ensure that the document has been assembled
+ *
+ * @return $this
+ */
+ public function ensureAssembled()
+ {
+ if (! $this->hasBeenAssembled) {
+ $this->hasBeenAssembled = true;
+ $this->assemble();
+
+ $this->emit(static::ON_ASSEMBLED, [$this]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get whether the document is empty
+ *
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ $this->ensureAssembled();
+
+ return empty($this->content);
+ }
+
+ /**
+ * Render the content to HTML but ignore any wrapper
+ *
+ * @return string
+ */
+ public function renderUnwrapped()
+ {
+ $this->ensureAssembled();
+ $html = [];
+
+ // This **must** be consumed after the document's assembly but before rendering the content.
+ // If the document consumes it during assembly, nothing happens. If the document is used as
+ // wrapper for another element, consuming it asap prevents a left-over reference and avoids
+ // the element from getting rendered multiple times.
+ $wrapped = $this->consumeWrapped();
+
+ $content = $this->getContent();
+ if ($wrapped !== null && ! $this->contains($wrapped) && ! $this->isIntermediateWrapper($wrapped)) {
+ $content[] = $wrapped;
+ }
+
+ foreach ($content as $element) {
+ if ($element instanceof self) {
+ $element->renderedBy = $this;
+ }
+
+ $html[] = $element->render();
+
+ if ($element instanceof self) {
+ $element->renderedBy = null;
+ }
+ }
+
+ return implode($this->contentSeparator, $html);
+ }
+
+ public function __clone()
+ {
+ foreach ($this->content as $key => $element) {
+ $this->content[$key] = clone $element;
+ }
+
+ $this->reIndexContent();
+ }
+
+ /**
+ * Render content to HTML when treated like a string
+ *
+ * Calls {@link render()} internally in order to render the text to HTML.
+ * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}.
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return Error::render($e);
+ }
+ }
+
+ /**
+ * Assemble the document
+ *
+ * Override this method in order to provide content in concrete classes.
+ */
+ protected function assemble()
+ {
+ }
+
+ /**
+ * Render the document to HTML respecting the set wrapper
+ *
+ * @return string
+ */
+ protected function renderWrapped()
+ {
+ $wrapper = $this->wrapper;
+
+ if (isset($this->renderedBy)) {
+ if ($wrapper === $this->renderedBy || $wrapper->contains($this->renderedBy)) {
+ // $this might be an intermediate wrapper that's already about to be rendered.
+ // In case of an element (referencing $this as a wrapper) that is a child of an
+ // outer wrapper, it is required to ignore $wrapper as otherwise it's a loop.
+ // ($wrapper then is in the render path of the outer wrapper and sideways "stolen")
+ return $this->renderUnwrapped();
+ }
+
+ $wrapper->renderedBy = $this->renderedBy;
+ } elseif (isset($wrapper->renderedBy)) {
+ throw new RuntimeException('Wrapper loop detected');
+ } else {
+ $this->renderedBy = $wrapper;
+ }
+
+ $html = $wrapper->renderWrappedDocument($this);
+
+ if (isset($this->renderedBy)) {
+ if ($this->renderedBy === $wrapper) {
+ $this->renderedBy = null;
+ } elseif ($wrapper->renderedBy === $this->renderedBy) {
+ $wrapper->renderedBy = null;
+ }
+ }
+
+ return $html;
+ }
+
+ /**
+ * Render the given document to HTML by treating this document as the wrapper
+ *
+ * @param HtmlDocument $document
+ *
+ * @return string
+ */
+ protected function renderWrappedDocument(HtmlDocument $document)
+ {
+ return $this->setWrapped($document)->render();
+ }
+
+ #[\ReturnTypeWillChange]
+ public function count()
+ {
+ return count($this->content);
+ }
+
+ public function getWrapper()
+ {
+ return $this->wrapper;
+ }
+
+ public function setWrapper(Wrappable $wrapper)
+ {
+ $this->wrapper = $wrapper;
+
+ return $this;
+ }
+
+ public function addWrapper(Wrappable $wrapper)
+ {
+ if ($this->wrapper === null) {
+ $this->setWrapper($wrapper);
+ } else {
+ $this->wrapper->addWrapper($wrapper);
+ }
+
+ return $this;
+ }
+
+ public function prependWrapper(Wrappable $wrapper)
+ {
+ if ($this->wrapper === null) {
+ $this->setWrapper($wrapper);
+ } else {
+ $wrapper->addWrapper($this->wrapper);
+ $this->setWrapper($wrapper);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Check whether the given element wraps this document (recursively)
+ *
+ * @param ValidHtml $element
+ *
+ * @return bool
+ */
+ protected function wrappedBy(ValidHtml $element)
+ {
+ if ($this->wrapper === null) {
+ return false;
+ }
+
+ if ($this->wrapper === $element || $this->wrapper->wrappedBy($element)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get whether the given element is an intermediate wrapper
+ *
+ * @param ValidHtml $element
+ *
+ * @return bool
+ */
+ protected function isIntermediateWrapper(ValidHtml $element): bool
+ {
+ foreach ($this->content as $child) {
+ if ($child instanceof self && $child->wrappedBy($element)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function render()
+ {
+ $this->ensureAssembled();
+ if ($this->wrapper === null) {
+ return $this->renderUnwrapped();
+ } else {
+ return $this->renderWrapped();
+ }
+ }
+
+ private function addIndexedContent(ValidHtml $html)
+ {
+ $pos = count($this->content);
+ $this->content[$pos] = $html;
+ $this->addObjectPosition($html, $pos);
+ }
+
+ private function addObjectPosition(ValidHtml $html, $pos)
+ {
+ $key = spl_object_hash($html);
+ if (array_key_exists($key, $this->contentIndex)) {
+ $this->contentIndex[$key][] = $pos;
+ } else {
+ $this->contentIndex[$key] = [$pos];
+ }
+ }
+
+ private function incrementIndexKeys()
+ {
+ foreach ($this->contentIndex as & $index) {
+ foreach ($index as & $pos) {
+ $pos++;
+ }
+ }
+ }
+
+ private function reIndexContent()
+ {
+ $this->contentIndex = [];
+ foreach ($this->content as $pos => $html) {
+ $this->addObjectPosition($html, $pos);
+ }
+ }
+}
diff --git a/vendor/ipl/html/src/HtmlElement.php b/vendor/ipl/html/src/HtmlElement.php
new file mode 100644
index 0000000..4f5d162
--- /dev/null
+++ b/vendor/ipl/html/src/HtmlElement.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace ipl\Html;
+
+/**
+ * The HtmlElement represents any HTML element
+ *
+ * A typical HTML element includes a tag, attributes and content.
+ */
+class HtmlElement extends BaseHtmlElement
+{
+ /**
+ * Create a new HTML element from the given tag, attributes and content
+ *
+ * @param string $tag The tag for the element
+ * @param Attributes $attributes The HTML attributes for the element
+ * @param ValidHtml ...$content The content of the element
+ */
+ public function __construct($tag, Attributes $attributes = null, ValidHtml ...$content)
+ {
+ $this->tag = $tag;
+
+ if ($attributes !== null) {
+ $this->getAttributes()->merge($attributes);
+ }
+
+ $this->setHtmlContent(...$content);
+ }
+
+ /**
+ * Create a new HTML element from the given tag, attributes and content
+ *
+ * @param string $tag The tag for the element
+ * @param mixed $attributes The HTML attributes for the element
+ * @param mixed $content The content of the element
+ *
+ * @return static
+ */
+ public static function create($tag, $attributes = null, $content = null)
+ {
+ return new static($tag, Attributes::wantAttributes($attributes), ...Html::wantHtmlList($content));
+ }
+}
diff --git a/vendor/ipl/html/src/HtmlString.php b/vendor/ipl/html/src/HtmlString.php
new file mode 100644
index 0000000..e62086b
--- /dev/null
+++ b/vendor/ipl/html/src/HtmlString.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace ipl\Html;
+
+/**
+ * HTML string
+ *
+ * HTML strings promise to be already escaped and can be anything from simple text to full HTML markup.
+ */
+class HtmlString extends Text
+{
+ protected $escaped = true;
+}
diff --git a/vendor/ipl/html/src/Table.php b/vendor/ipl/html/src/Table.php
new file mode 100644
index 0000000..28a6738
--- /dev/null
+++ b/vendor/ipl/html/src/Table.php
@@ -0,0 +1,226 @@
+<?php
+
+namespace ipl\Html;
+
+use RuntimeException;
+use stdClass;
+
+class Table extends BaseHtmlElement
+{
+ protected $contentSeparator = "\n";
+
+ /** @var string */
+ protected $tag = 'table';
+
+ /** @var HtmlElement */
+ private $caption;
+
+ /** @var HtmlElement */
+ private $header;
+
+ /** @var HtmlElement */
+ private $body;
+
+ /** @var HtmlElement */
+ private $footer;
+
+ public function addHtml(ValidHtml ...$content)
+ {
+ foreach ($content as $html) {
+ if ($html instanceof BaseHtmlElement) {
+ switch ($html->getTag()) {
+ case 'tr':
+ $this->getBody()->addHtml($html);
+
+ break;
+ case 'thead':
+ parent::addHtml($html);
+ $this->header = $html;
+
+ break;
+ case 'tbody':
+ parent::addHtml($html);
+ $this->body = $html;
+
+ break;
+ case 'tfoot':
+ parent::addHtml($html);
+ $this->footer = $html;
+
+ break;
+ case 'caption':
+ if ($this->caption === null) {
+ $this->prependHtml($html);
+ $this->caption = $html;
+ } else {
+ throw new RuntimeException(
+ 'Tables allow only one <caption> tag'
+ );
+ }
+
+ break;
+ default:
+ $this->getBody()->addHtml(static::row([$html]));
+ }
+ } else {
+ $this->getBody()->addHtml(static::row([$html]));
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param mixed $content
+ * @return $this
+ */
+ public function add($content)
+ {
+ if ($content instanceof stdClass) {
+ $this->getBody()->addHtml(static::row((array) $content));
+ } elseif (is_iterable($content)) {
+ $this->getBody()->addHtml(static::row($content));
+ } elseif ($content instanceof ValidHtml) {
+ $this->addHtml($content);
+ } else {
+ $this->getBody()->addHtml(static::row([$content]));
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set the table title
+ *
+ * Will be rendered as a "caption" HTML element
+ *
+ * @param mixed $caption
+ * @return $this
+ */
+ public function setCaption($caption)
+ {
+ if ($caption instanceof BaseHtmlElement && $caption->getTag() === 'caption') {
+ $this->caption = $caption;
+ $this->prependHtml($caption);
+ } elseif ($this->caption === null) {
+ $this->caption = new HtmlElement('caption', null, ...Html::wantHtmlList($caption));
+ $this->prependHtml($this->caption);
+ } else {
+ $this->caption->setContent($caption);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Static helper creating a tr element
+ *
+ * @param Attributes|array $attributes
+ * @param Html|array|string $content
+ * @return HtmlElement
+ */
+ public static function tr($content = null, $attributes = null)
+ {
+ return Html::tag('tr', $attributes, $content);
+ }
+
+ /**
+ * Static helper creating a th element
+ *
+ * @param Attributes|array $attributes
+ * @param Html|array|string $content
+ * @return HtmlElement
+ */
+ public static function th($content = null, $attributes = null)
+ {
+ return Html::tag('th', $attributes, $content);
+ }
+
+ /**
+ * Static helper creating a td element
+ *
+ * @param Attributes|array $attributes
+ * @param Html|array|string $content
+ * @return HtmlElement
+ */
+ public static function td($content = null, $attributes = null)
+ {
+ return Html::tag('td', $attributes, $content);
+ }
+
+ /**
+ * @param $row
+ * @param null $attributes
+ * @param string $tag
+ * @return HtmlElement
+ */
+ public static function row($row, $attributes = null, $tag = 'td')
+ {
+ $tr = static::tr();
+ foreach ((array) $row as $value) {
+ $tr->addHtml(Html::tag($tag, null, $value));
+ }
+
+ if ($attributes !== null) {
+ $tr->setAttributes($attributes);
+ }
+
+ return $tr;
+ }
+
+ /**
+ * @return HtmlElement
+ */
+ public function getBody()
+ {
+ if ($this->body === null) {
+ $this->addHtml(Html::tag('tbody')->setSeparator("\n"));
+ }
+
+ return $this->body;
+ }
+
+ /**
+ * @return HtmlElement
+ */
+ public function getHeader()
+ {
+ if ($this->header === null) {
+ $this->addHtml(Html::tag('thead')->setSeparator("\n"));
+ }
+
+ return $this->header;
+ }
+
+ /**
+ * @return HtmlElement
+ */
+ public function getFooter()
+ {
+ if ($this->footer === null) {
+ $this->addHtml(Html::tag('tfoot')->setSeparator("\n"));
+ }
+
+ return $this->footer;
+ }
+
+ /**
+ * @return HtmlElement
+ */
+ public function nextBody()
+ {
+ $this->body = null;
+
+ return $this->getBody();
+ }
+
+ /**
+ * @return HtmlElement
+ */
+ public function nextHeader()
+ {
+ $this->header = null;
+
+ return $this->getHeader();
+ }
+}
diff --git a/vendor/ipl/html/src/TemplateString.php b/vendor/ipl/html/src/TemplateString.php
new file mode 100644
index 0000000..b1e01c0
--- /dev/null
+++ b/vendor/ipl/html/src/TemplateString.php
@@ -0,0 +1,175 @@
+<?php
+
+namespace ipl\Html;
+
+use Exception;
+
+/**
+ * Render {{#mustache}}Mustache{{/mustache}}-like string from {@link ValidHtml} element arguments
+ *
+ * # Example Usage
+ * ```
+ * $info = TemplateString::create(
+ * 'Follow the {{#doc}}HTML documentation{{/doc}} for more information on {{#strong}}HTML elements{{/strong}}',
+ * [
+ * 'doc' => new Link(null, 'doc/html'),
+ * 'strong' => Html::tag('strong')
+ * ]
+ * );
+ * ```
+ */
+class TemplateString extends FormattedString
+{
+ /** @var array */
+ protected $templateArgs = [];
+
+ /** @var int */
+ protected $pos = 0;
+
+ /** @var string */
+ protected $string;
+
+ /** @var int */
+ protected $length;
+
+ public function __construct($format, $args = null)
+ {
+ $parentArgs = [];
+ foreach ($args ?: [] as $val) {
+ if (is_array($val) && is_string(key($val))) {
+ $this->templateArgs += $val;
+ } else {
+ $parentArgs[] = $val;
+ }
+ }
+
+ parent::__construct($format, $parentArgs);
+ }
+
+ /**
+ * Parse template strings
+ *
+ * @param ?string $for template name
+ * @return HtmlDocument
+ * @throws Exception in case of missing template argument or unbounded open or close templates
+ */
+ protected function parseTemplates($for = null)
+ {
+ $buffer = '';
+
+ while (($char = $this->readChar()) !== false) {
+ if ($char !== '{') {
+ $buffer .= $char;
+ continue;
+ }
+
+ $nextChar = $this->readChar();
+ if ($nextChar !== '{') {
+ $buffer .= $char . $nextChar;
+ continue;
+ }
+
+ $templateHandle = $this->readChar();
+ $start = $templateHandle === '#';
+ $end = $templateHandle === '/';
+
+ $templateKey = $this->readUntil('}');
+ // if the string following '{{#' is read up to the last character or (length - 1)th character
+ // then it is not a template
+ if ($this->pos >= $this->length - 1) {
+ $buffer .= $char . $nextChar . $templateHandle . $templateKey;
+ continue;
+ }
+
+ $this->pos++;
+ $closeChar = $this->readChar();
+
+ if ($closeChar !== '}') {
+ $buffer .= $char . $nextChar . $templateHandle . $templateKey . '}' . $closeChar;
+ continue;
+ }
+
+ if ($start) {
+ if (isset($this->templateArgs[$templateKey])) {
+ $wrapper = $this->templateArgs[$templateKey];
+
+ $buffer .= $this->parseTemplates($templateKey)->prependWrapper($wrapper);
+ } else {
+ throw new Exception(sprintf(
+ 'Missing template argument: %s ',
+ $templateKey
+ ));
+ }
+ } elseif ($for === $templateKey && $end) {
+ // close the template
+ $for = null;
+ break;
+ } else {
+ // throw exception for unbounded closing of templates
+ throw new Exception(sprintf(
+ 'Unbound closing of template: %s',
+ $templateKey
+ ));
+ }
+ }
+
+ if ($this->pos === $this->length && $for !== null) {
+ throw new Exception(sprintf(
+ 'Unbound opening of template: %s',
+ $for
+ ));
+ }
+
+ return (new HtmlDocument())->addHtml(HtmlString::create($buffer));
+ }
+
+ /**
+ * Read until any of the given chars appears
+ *
+ * @param string ...$chars
+ *
+ * @return string
+ */
+ protected function readUntil(...$chars)
+ {
+ $buffer = '';
+ while (($c = $this->readChar()) !== false) {
+ if (in_array($c, $chars, true)) {
+ $this->pos--;
+ break;
+ }
+
+ $buffer .= $c;
+ }
+
+ return $buffer;
+ }
+
+ /**
+ * Read a single character
+ *
+ * @return false|string false if there is no character left
+ */
+ protected function readChar()
+ {
+ if ($this->length > $this->pos) {
+ return $this->string[$this->pos++];
+ }
+
+ return false;
+ }
+
+ public function render()
+ {
+ $formattedstring = parent::render();
+ if (empty($this->templateArgs)) {
+ return $formattedstring;
+ }
+
+ $this->string = $formattedstring;
+
+ $this->length = strlen($formattedstring);
+
+ return $this->parseTemplates()->render();
+ }
+}
diff --git a/vendor/ipl/html/src/Text.php b/vendor/ipl/html/src/Text.php
new file mode 100644
index 0000000..710e3d9
--- /dev/null
+++ b/vendor/ipl/html/src/Text.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace ipl\Html;
+
+use Exception;
+
+/**
+ * A text node
+ *
+ * Primitive element that renders text to HTML while automatically escaping its content.
+ * If the passed content is already escaped, see {@link setEscaped()} to indicate this.
+ */
+class Text implements ValidHtml
+{
+ /** @var string */
+ protected $content;
+
+ /** @var bool Whether the content is already escaped */
+ protected $escaped = false;
+
+ /**
+ * Create a new text node
+ *
+ * @param string $content
+ */
+ public function __construct($content)
+ {
+ $this->setContent($content);
+ }
+
+ /**
+ * Create a new text node
+ *
+ * @param string $content
+ *
+ * @return static
+ */
+ public static function create($content)
+ {
+ return new static($content);
+ }
+
+ /**
+ * Get the content
+ *
+ * @return string
+ */
+ public function getContent()
+ {
+ return $this->content;
+ }
+
+ /**
+ * Set the content
+ *
+ * @param string $content
+ *
+ * @return $this
+ */
+ public function setContent($content)
+ {
+ $this->content = (string) $content;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the content promises to be already escaped
+ *
+ * @return bool
+ */
+ public function isEscaped()
+ {
+ return $this->escaped;
+ }
+
+ /**
+ * Set whether the content is already escaped
+ *
+ * @param bool $escaped
+ *
+ * @return $this
+ */
+ public function setEscaped($escaped = true)
+ {
+ $this->escaped = $escaped;
+
+ return $this;
+ }
+
+ /**
+ * Render text to HTML when treated like a string
+ *
+ * Calls {@link render()} internally in order to render the text to HTML.
+ * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}.
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ try {
+ return $this->render();
+ } catch (Exception $e) {
+ return Error::render($e);
+ }
+ }
+
+ public function render()
+ {
+ if ($this->escaped) {
+ return $this->content;
+ } else {
+ return Html::escape($this->content);
+ }
+ }
+}
diff --git a/vendor/ipl/html/src/ValidHtml.php b/vendor/ipl/html/src/ValidHtml.php
new file mode 100644
index 0000000..2b88af4
--- /dev/null
+++ b/vendor/ipl/html/src/ValidHtml.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace ipl\Html;
+
+/**
+ * Interface for HTML elements or primitives that promise to render valid UTF-8 encoded HTML5 with special characters
+ * converted to HTML entities
+ */
+interface ValidHtml
+{
+ /**
+ * Render to HTML
+ *
+ * @return string UTF-8 encoded HTML5 with special characters converted to HTML entities
+ */
+ public function render();
+}