src/Elements/Attributes/Attributes.php

Summary

Maintainability
A
3 hrs
Test Coverage
A
98%
<?php

namespace Galahad\Aire\Elements\Attributes;

use ArrayAccess;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;

/**
 * @property \Galahad\Aire\Elements\Attributes\ClassNames $class
 */
class Attributes implements Htmlable, ArrayAccess, Arrayable
{
    /**
     * @var array
     */
    protected $items;
    
    /**
     * @var array
     */
    protected $defaults = [];
    
    /**
     * Callbacks to mutate attribute values
     *
     * @var array
     */
    protected $mutators = [];
    
    /**
     * Constructor
     *
     * @param array $items
     */
    public function __construct(array ...$items)
    {
        $this->items = array_merge([], ...$items);
    }
    
    /**
     * Register a mutator for an attribute
     *
     * When fetching the attribute, even if it does not exist, this mutator will
     * be called. This provides an opportunity to mutate or calculate the value
     * of the attribute based on outside data (for example, data binding).
     *
     * @param string $attribute
     * @param callable $mutator
     * @return \Galahad\Aire\Elements\Attributes\Attributes
     */
    public function registerMutator(string $attribute, callable $mutator) : self
    {
        if (!isset($this->mutators[$attribute])) {
            $this->mutators[$attribute] = [];
        }
        
        $this->mutators[$attribute][] = $mutator;
        
        return $this;
    }
    
    /**
     * Get an attribute value, optionally with a fallback default
     *
     * @param $key
     * @param null $default
     * @return mixed|null
     */
    public function get($key, $default = null)
    {
        if (isset($this->defaults[$key]) || $this->offsetExists($key)) {
            return $this->offsetGet($key);
        }
        
        return value($default);
    }
    
    /**
     * Check if an attribute exists
     *
     * @param $key
     * @return bool
     */
    public function has($key) : bool
    {
        return $this->offsetExists($key);
    }
    
    /**
     * Set an attribute value
     *
     * @param $key
     * @param $value
     * @return \Galahad\Aire\Elements\Attributes\Attributes
     */
    public function set($key, $value) : self
    {
        $this->offsetSet($key, $value);
        
        return $this;
    }
    
    /**
     * Removes an attribute
     *
     * @param $key
     * @return \Galahad\Aire\Elements\Attributes\Attributes
     */
    public function unset($key) : self
    {
        $this->offsetUnset($key);
        
        return $this;
    }
    
    /**
     * @inheritdoc
     *
     * @param mixed $key
     * @return bool
     */
    public function offsetExists($key) : bool
    {
        if (isset($this->items[$key])) {
            return true;
        }
        
        if (isset($this->mutators[$key])) {
            return null !== $this->offsetGet($key);
        }
        
        return false;
    }
    
    /**
     * @inheritdoc
     *
     * @param mixed $key
     * @return mixed|null
     */
    public function offsetGet($key)
    {
        $value = $this->items[$key] ?? null;
        
        if (isset($this->mutators[$key])) {
            foreach ($this->mutators[$key] as $mutator) {
                $mutated = $mutator($value);
                if ('class' !== $key || null !== $mutated) {
                    $value = $mutated;
                }
            }
        }
        
        // Use the default value if all else fails
        if (null === $value && isset($this->defaults[$key])) {
            return $this->defaults[$key];
        }
        
        return $value;
    }
    
    /**
     * @inheritdoc
     *
     * @param mixed $key
     * @param mixed $value
     */
    public function offsetSet($key, $value) : void
    {
        if ('class' === $key) {
            $this->items['class']->set($value);
        } else {
            $this->items[$key] = $value;
        }
    }
    
    /**
     * @inheritdoc
     *
     * @param mixed $key
     */
    public function offsetUnset($key) : void
    {
        unset($this->items[$key]);
    }
    
    /**
     * Get attribute using object notation
     *
     * @param $name
     * @return mixed|null
     */
    public function __get($name)
    {
        return $this->offsetGet($name);
    }
    
    /**
     * Set attribute using object notation
     *
     * @param $name
     * @param $value
     */
    public function __set($name, $value)
    {
        $this->offsetSet($name, $value);
    }
    
    /**
     * Check if attribute is set using object notation
     *
     * @param $name
     * @return bool
     */
    public function __isset($name)
    {
        return $this->offsetExists($name);
    }
    
    /**
     * Unset attribute using object notation
     *
     * @param $name
     */
    public function __unset($name)
    {
        $this->offsetUnset($name);
    }
    
    /**
     * Set a default/fallback value
     *
     * @param string $attribute
     * @param mixed|callable $default
     * @return \Galahad\Aire\Elements\Attributes\Attributes
     */
    public function setDefault(string $attribute, $default) : self
    {
        // If the default value is a closure, register it as a mutator
        if ($default instanceof \Closure) {
            return $this->registerMutator($attribute, function($value) use ($default) {
                return $value ?? $default();
            });
        }
        
        $this->defaults[$attribute] = $default;
        
        return $this;
    }
    
    /**
     * Check if the "value" attribute matches a given value
     *
     * This function will cast string values to the same type as the
     * current "value" attribute ("1" === true, etc)
     *
     * @param mixed $check_value
     * @return bool
     */
    public function isValue($check_value) : bool
    {
        if (null === $check_value) {
            return false;
        }
        
        $current_value = $this->get('value');
        
        if ($current_value instanceof Collection) {
            return $current_value->contains($check_value);
        }
        
        if (is_array($current_value)) {
            return in_array($check_value, $current_value, false);
        }
        
        /** @noinspection TypeUnsafeComparisonInspection **/
        return $check_value == $current_value;
    }
    
    /**
     * Exclude certain keys from being included when rendering to HTML
     *
     * @param mixed ...$keys
     * @return \Galahad\Aire\Elements\Attributes\Attributes
     */
    public function except(...$keys) : self
    {
        $filtered_attributes = new static(Arr::except($this->items, $keys));
        $filtered_attributes->defaults = Arr::except($this->defaults, $keys);
        $filtered_attributes->mutators = Arr::except($this->mutators, $keys);
        
        return $filtered_attributes;
    }
    
    /**
     * Only use certain keys when rendering to HTML
     *
     * @param mixed ...$keys
     * @return \Galahad\Aire\Elements\Attributes\Attributes
     */
    public function only(...$keys) : self
    {
        $filtered_attributes = new static(Arr::only($this->items, $keys));
        $filtered_attributes->defaults = Arr::only($this->defaults, $keys);
        $filtered_attributes->mutators = Arr::only($this->mutators, $keys);
        
        return $filtered_attributes;
    }
    
    /**
     * Render attributes to key="value" pairs
     *
     * @return string
     */
    public function toHtml() : string
    {
        return $this->toCollection()
            ->filter(function($value, $key) {
                return (false !== $value || (false === $value && 'value' === $key))
                    && null !== $value
                    && !('' === $value && 'class' === $key)
                    && !is_array($value); // Array values have to be handled in associated component
            })
            ->map(function($value, $name) {
                $name = strtolower($name);
                
                // Cast boolean values to '1' or '0'
                if ('value' === $name && is_bool($value)) {
                    $value = $value ? '1' : '0';
                }
                
                return true === $value
                    ? $name
                    : sprintf('%s="%s"', $name, e($value));
            })
            ->implode(' ');
    }
    
    public function __toString()
    {
        return $this->toHtml();
    }
    
    /**
     * Get a collection of all attributes (after mutation)
     *
     * @return \Illuminate\Support\Collection
     */
    public function toCollection() : Collection
    {
        return new Collection($this->toArray());
    }
    
    /**
     * Get an array of all attributes (after mutation, and with defaults)
     *
     * @return array
     */
    public function toArray() : array
    {
        // We want to get values for keys that are in the attribute list,
        // but also need to load defaults and anything that has a mutator
        
        $keys = array_unique(array_merge(
            array_keys($this->items),
            array_keys($this->defaults),
            array_keys($this->mutators)
        ));
        
        // Once we've loaded a list of all keys, we'll call offsetGet()
        // to apply mutators and default values
        
        $array = [];
        foreach ($keys as $key) {
            $array[$key] = $this->offsetGet($key);
        }
        
        return $array;
    }
}